mirror of
https://github.com/rajnandan1/kener.git
synced 2026-08-07 23:32:02 +00:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db6cb6cf7d | |||
| babeeb75b2 | |||
| e0187605e7 | |||
| e2861f1e59 | |||
| 01aa4d9984 | |||
| 555372f175 | |||
| bd5fd409d1 | |||
| f61d82e13f | |||
| f39588fff7 | |||
| 0716f271df | |||
| 1085c3e561 | |||
| 93907e6d96 | |||
| d03fd63c64 | |||
| 3ff43af787 | |||
| 7f2fef8e8e | |||
| 7658170865 | |||
| b1f8a505a5 | |||
| 104da58646 | |||
| ed6e97e8c9 | |||
| 0b25274874 | |||
| bb8ab41bfe | |||
| b43a5fb343 | |||
| 554caa5018 | |||
| ffe7403043 | |||
| a26d0ece59 | |||
| a4277f7ed0 | |||
| f75aaf9cef | |||
| 4e1ecf41ee | |||
| f23fb5313c | |||
| 18489c5339 | |||
| 4044bae26d | |||
| 02686caa78 | |||
| 797aef80d6 | |||
| a8841ad8a3 | |||
| 0b2cd5fc8a | |||
| 9d349716e5 | |||
| 92d068ef49 | |||
| c6e3620151 | |||
| d92165d0f8 | |||
| a56bbead8d | |||
| db3fb923f0 | |||
| bb9d88a095 | |||
| 912da6b8f4 | |||
| 3b07623346 | |||
| 702ceca9b0 | |||
| 21f2433919 | |||
| 4e7791b104 | |||
| f79c24c80d | |||
| 087c2f25fb |
@@ -0,0 +1,123 @@
|
||||
---
|
||||
name: ss-shadcn-svelte
|
||||
description: >
|
||||
Use shadcn-svelte components in SvelteKit projects. Detects whether the current project is a SvelteKit
|
||||
app with shadcn-svelte installed, lists available components, and provides access to full component
|
||||
documentation via the official llms.txt. Helps choose the right UI components for the job — buttons,
|
||||
forms, dialogs, tables, charts, and more — following shadcn-svelte best practices.
|
||||
Use this skill whenever the user is working in a SvelteKit project and wants to: add UI components,
|
||||
build forms, create dialogs or modals, add a data table, use a date picker, build a sidebar or
|
||||
navigation, add charts, use a combobox or select, create an alert or toast notification, or generally
|
||||
build UI with pre-built accessible components. Also trigger when the user mentions "shadcn", "shadcn-svelte",
|
||||
"bits-ui", or asks about available components in their Svelte project.
|
||||
---
|
||||
|
||||
# shadcn-svelte — Component-Aware Svelte UI Assistant
|
||||
|
||||
Use the right shadcn-svelte components when building UI in SvelteKit projects. This skill detects your project setup, shows what's available, and gives you access to full component documentation.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The project must be a SvelteKit app with shadcn-svelte initialized:
|
||||
|
||||
```bash
|
||||
# Initialize shadcn-svelte in an existing SvelteKit project
|
||||
npx shadcn-svelte@latest init
|
||||
```
|
||||
|
||||
## How to use
|
||||
|
||||
### Step 1: Detect project setup
|
||||
|
||||
Run the detection script to verify this is a SvelteKit project with shadcn-svelte and see which components are already installed:
|
||||
|
||||
```bash
|
||||
bash <skill-path>/scripts/detect.sh .
|
||||
```
|
||||
|
||||
This will:
|
||||
- Confirm it's a SvelteKit project (checks for `svelte.config.js/ts` and `@sveltejs/kit` in package.json)
|
||||
- Confirm shadcn-svelte is installed (checks for `components.json`, `bits-ui`, or `shadcn-svelte` in package.json)
|
||||
- List all currently installed components in the project's UI directory
|
||||
- Provide the documentation URL
|
||||
|
||||
If the script exits with code 1, the project either isn't SvelteKit or doesn't have shadcn-svelte — do not proceed with shadcn-svelte components in that case.
|
||||
|
||||
### Step 2: Read the component documentation
|
||||
|
||||
The full component documentation for LLMs is available at:
|
||||
|
||||
```
|
||||
https://www.shadcn-svelte.com/llms.txt
|
||||
```
|
||||
|
||||
Fetch this URL to get a structured index of all available components organized by category, with links to individual component documentation pages (in `.md` format).
|
||||
|
||||
When you need to use a specific component, read its individual documentation page from the links provided in `llms.txt`. Each component doc includes:
|
||||
- Import statements and usage examples
|
||||
- Available props, events, and slots
|
||||
- Variants and configuration options
|
||||
- Accessibility information
|
||||
|
||||
### Step 3: Use the right component for the job
|
||||
|
||||
When building UI, follow this decision process:
|
||||
|
||||
1. **Run detection** to confirm shadcn-svelte is available and see installed components
|
||||
2. **Fetch llms.txt** to see all available components
|
||||
3. **Read the specific component docs** for the components you plan to use
|
||||
4. **Check if the component is installed** — if not, add it:
|
||||
```bash
|
||||
npx shadcn-svelte@latest add <component-name>
|
||||
```
|
||||
5. **Import and use the component** following the documentation patterns
|
||||
|
||||
### Component categories
|
||||
|
||||
shadcn-svelte components are organized into these categories:
|
||||
|
||||
| Category | Components |
|
||||
|----------|-----------|
|
||||
| **Layout** | Aspect Ratio, Collapsible, Resizable, Scroll Area, Separator, Sidebar |
|
||||
| **Form & Input** | Button, Calendar, Checkbox, Combobox, Date Picker, Input, Input OTP, Label, Radio Group, Range Calendar, Select, Slider, Switch, Textarea, Toggle, Toggle Group |
|
||||
| **Data Display** | Accordion, Avatar, Badge, Card, Carousel, Chart, Table, Data Table |
|
||||
| **Feedback** | Alert, Alert Dialog, Progress, Skeleton, Sonner (Toast) |
|
||||
| **Overlay** | Context Menu, Dialog, Drawer, Dropdown Menu, Hover Card, Menubar, Popover, Sheet, Tooltip |
|
||||
| **Navigation** | Breadcrumb, Command, Pagination, Tabs |
|
||||
| **Typography** | Typography |
|
||||
|
||||
### Adding new components
|
||||
|
||||
```bash
|
||||
# Add a single component
|
||||
npx shadcn-svelte@latest add button
|
||||
|
||||
# Add multiple components
|
||||
npx shadcn-svelte@latest add button card dialog
|
||||
|
||||
# List all available components
|
||||
npx shadcn-svelte@latest add
|
||||
```
|
||||
|
||||
### Import patterns
|
||||
|
||||
Components are typically imported from the project's `$lib/components/ui` directory:
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
</script>
|
||||
```
|
||||
|
||||
Some components use namespace imports (with `* as`) when they have multiple sub-components (Card, Dialog, Sheet, Table, etc.), while simpler components use named imports (Button, Input, Badge, etc.).
|
||||
|
||||
## Important guidelines
|
||||
|
||||
- **Always run detection first** before suggesting shadcn-svelte components
|
||||
- **Always read component docs** before using a component — don't guess at props or patterns
|
||||
- **Check installed components** and add missing ones before importing
|
||||
- **Use the project's configured path** — the components directory may vary based on `components.json` configuration
|
||||
- **Follow Svelte 5 patterns** — shadcn-svelte uses runes (`$state`, `$derived`, `$effect`) and snippet-based composition
|
||||
- **Prefer composition** — shadcn-svelte components are designed to be composed together, not used as monolithic blocks
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# detect.sh — Check if the current project is a SvelteKit project with shadcn-svelte installed.
|
||||
# Exits 0 and prints component info if detected, exits 1 otherwise.
|
||||
|
||||
PROJECT_DIR="${1:-.}"
|
||||
|
||||
# Resolve to absolute path
|
||||
PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)"
|
||||
|
||||
# --- Step 1: Check for SvelteKit ---
|
||||
|
||||
SVELTEKIT=false
|
||||
|
||||
# Check for svelte.config.js or svelte.config.ts
|
||||
if [[ -f "$PROJECT_DIR/svelte.config.js" ]] || [[ -f "$PROJECT_DIR/svelte.config.ts" ]]; then
|
||||
SVELTEKIT=true
|
||||
fi
|
||||
|
||||
# Also verify package.json has @sveltejs/kit
|
||||
if [[ -f "$PROJECT_DIR/package.json" ]]; then
|
||||
if grep -q '"@sveltejs/kit"' "$PROJECT_DIR/package.json" 2>/dev/null; then
|
||||
SVELTEKIT=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$SVELTEKIT" != "true" ]]; then
|
||||
echo "NOT_SVELTEKIT"
|
||||
echo "This is not a SvelteKit project. No svelte.config.js/ts found and @sveltejs/kit is not in package.json."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Step 2: Check for shadcn-svelte ---
|
||||
|
||||
SHADCN=false
|
||||
|
||||
# Check for components.json (shadcn-svelte config file)
|
||||
if [[ -f "$PROJECT_DIR/components.json" ]]; then
|
||||
# Verify it's actually a shadcn config (has $schema or style field)
|
||||
if grep -qE '"(\$schema|style)"' "$PROJECT_DIR/components.json" 2>/dev/null; then
|
||||
SHADCN=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for bits-ui in package.json (core dependency of shadcn-svelte)
|
||||
if [[ -f "$PROJECT_DIR/package.json" ]]; then
|
||||
if grep -q '"bits-ui"' "$PROJECT_DIR/package.json" 2>/dev/null; then
|
||||
SHADCN=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for shadcn-svelte in package.json
|
||||
if [[ -f "$PROJECT_DIR/package.json" ]]; then
|
||||
if grep -q '"shadcn-svelte"' "$PROJECT_DIR/package.json" 2>/dev/null; then
|
||||
SHADCN=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$SHADCN" != "true" ]]; then
|
||||
echo "NO_SHADCN_SVELTE"
|
||||
echo "SvelteKit project detected, but shadcn-svelte is not installed."
|
||||
echo "Install it with: npx shadcn-svelte@latest init"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Step 3: Gather installed components ---
|
||||
|
||||
echo "DETECTED"
|
||||
echo "SvelteKit project with shadcn-svelte detected."
|
||||
echo ""
|
||||
|
||||
# Check which components are already installed by scanning the components directory
|
||||
COMPONENTS_DIR=""
|
||||
|
||||
# Try to read the components alias from components.json
|
||||
if [[ -f "$PROJECT_DIR/components.json" ]]; then
|
||||
# Extract the aliases.components path
|
||||
ALIAS_PATH=$(grep -o '"components"[[:space:]]*:[[:space:]]*"[^"]*"' "$PROJECT_DIR/components.json" | head -1 | sed 's/.*"components"[[:space:]]*:[[:space:]]*"//' | sed 's/"//')
|
||||
|
||||
if [[ -n "$ALIAS_PATH" ]]; then
|
||||
# Resolve $lib to src/lib
|
||||
RESOLVED_PATH="${ALIAS_PATH//\$lib/src/lib}"
|
||||
if [[ -d "$PROJECT_DIR/$RESOLVED_PATH/ui" ]]; then
|
||||
COMPONENTS_DIR="$PROJECT_DIR/$RESOLVED_PATH/ui"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback: check common locations
|
||||
if [[ -z "$COMPONENTS_DIR" ]]; then
|
||||
for dir in "src/lib/components/ui" "src/lib/ui" "src/components/ui"; do
|
||||
if [[ -d "$PROJECT_DIR/$dir" ]]; then
|
||||
COMPONENTS_DIR="$PROJECT_DIR/$dir"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -n "$COMPONENTS_DIR" ]] && [[ -d "$COMPONENTS_DIR" ]]; then
|
||||
echo "Installed components (in $COMPONENTS_DIR):"
|
||||
for comp_dir in "$COMPONENTS_DIR"/*/; do
|
||||
if [[ -d "$comp_dir" ]]; then
|
||||
comp_name=$(basename "$comp_dir")
|
||||
echo " - $comp_name"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "Documentation: https://www.shadcn-svelte.com/llms.txt"
|
||||
@@ -1,250 +0,0 @@
|
||||
---
|
||||
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
|
||||
- **After any agent run** — if the agent explored, read, or traced code to understand how part of the codebase works, that understanding must be captured (see Phase C)
|
||||
|
||||
**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, proceed. If no, skip Phase B entirely.
|
||||
|
||||
Then apply this filter to **every sentence** before writing:
|
||||
|
||||
> "Does this sentence describe how the code is structured, a design decision, or a constraint that would change how someone writes future code in this area?"
|
||||
|
||||
If no → cut it. This is the line between architecture documentation and a session diary.
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase C — Capture Agent Understanding (After Any Agent Run)
|
||||
|
||||
After completing any task (coding, debugging, research, exploration), review what you learned about the codebase during the session and persist anything not already documented.
|
||||
|
||||
### C1) Identify new understanding
|
||||
|
||||
Reflect on what you discovered during this session:
|
||||
|
||||
- How does a feature/module actually work? (code flow, data transformations, call chains)
|
||||
- What patterns or conventions did you observe across multiple files?
|
||||
- What dependencies or relationships between modules did you trace?
|
||||
- What surprised you or was non-obvious? (hidden side effects, implicit ordering, shared state)
|
||||
- What constraints or invariants did you discover that aren't documented anywhere?
|
||||
|
||||
### C2) Check if already documented
|
||||
|
||||
```bash
|
||||
ls .codecontext/
|
||||
grep -ril "<keyword>" .codecontext/
|
||||
```
|
||||
|
||||
Read matching files. If the understanding is already captured accurately, skip. If partially captured, update the relevant sections.
|
||||
|
||||
### C3) Write or update docs
|
||||
|
||||
Apply the same quality filters from Phase B (B1 architecture filter). Then:
|
||||
|
||||
- If the understanding maps to an existing `.codecontext/` file, update the relevant sections
|
||||
- If it covers a new domain area, create a new file following the B2 template and naming rules
|
||||
- Merge your new understanding with existing content — do not duplicate or contradict
|
||||
|
||||
### C4) Scope
|
||||
|
||||
This phase applies even when:
|
||||
|
||||
- The task was **read-only** (research, exploration, answering questions about code)
|
||||
- The task was a **bug investigation** that didn't result in a fix
|
||||
- The agent **traced code flow** to understand behavior before making changes
|
||||
- The agent **read multiple files** to understand how a feature works
|
||||
|
||||
This phase does **NOT** apply when:
|
||||
|
||||
- The agent only touched a single file and learned nothing non-obvious
|
||||
- The understanding is already fully captured in existing `.codecontext/` docs
|
||||
- The session was trivial (formatting, typo fix, config change)
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
|
||||
- [ ] Every sentence passed the B1 architecture filter
|
||||
- [ ] 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
|
||||
|
||||
After any agent run:
|
||||
|
||||
- [ ] Reviewed what was learned about the codebase during this session
|
||||
- [ ] Checked if that understanding is already in `.codecontext/`
|
||||
- [ ] Persisted any new architectural knowledge (even from read-only/research sessions)
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/ss-shadcn-svelte
|
||||
@@ -1,46 +0,0 @@
|
||||
# Database & Migrations
|
||||
|
||||
## Overview
|
||||
|
||||
Kener uses **Knex.js** as a database abstraction layer supporting three engines: **SQLite** (default, via `better-sqlite3`), **PostgreSQL** (`pg`), and **MySQL** (`mysql2`). The engine is selected at runtime from the `DATABASE_URL` environment variable prefix (`sqlite://`, `postgresql://`, `mysql://`).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Connection & Config
|
||||
|
||||
| File | Responsibility |
|
||||
| --------------------------------- | ---------------------------------------------------------- |
|
||||
| `knexfile.ts` | Parses `DATABASE_URL`, selects client, exports Knex config |
|
||||
| `src/lib/server/db/db.ts` | Singleton Knex instance — all app code imports from here |
|
||||
| `src/lib/server/db/dbimpl.js` | High-level DB methods (wraps repositories) |
|
||||
| `src/lib/server/db/repositories/` | Per-domain query classes extending `BaseRepository` |
|
||||
|
||||
### Repository Pattern
|
||||
|
||||
Each repository lives in `src/lib/server/db/repositories/<domain>.ts`, extends `BaseRepository` (which receives the Knex instance), and exposes typed async methods. Queries use Knex query builder — never raw SQL (except index creation wrapped in try/catch).
|
||||
|
||||
### Migrations
|
||||
|
||||
Migrations live in `migrations/` as TypeScript files with naming convention `YYYYMMDDHHMMSS_<description>.ts`.
|
||||
|
||||
Key patterns observed across all existing migrations:
|
||||
|
||||
- **Idempotency guards**: `knex.schema.hasTable` / `knex.schema.hasColumn` before `createTable` / `alterTable`.
|
||||
- **Column types**: Knex abstractions only (`.string()`, `.integer()`, `.text()`, `.float()`). No raw DDL.
|
||||
- **Defaults**: `.defaultTo()` + `.notNullable()` for YES/NO string flags (e.g., `is_hidden`, `is_owner`).
|
||||
- **Data seeding in migrations**: Standard Knex query builder (`.orderBy().first()`, `.update()`) — works on all three engines.
|
||||
- **Index creation**: Wrapped in `try/catch` because `CREATE INDEX IF NOT EXISTS` isn't portable.
|
||||
- **PostgreSQL insert returning**: Some repos branch on `GetDbType() === "postgresql"` to use `.returning("*")`, with fallback to re-read by inserted ID for SQLite/MySQL.
|
||||
|
||||
## Edge Cases and Gotchas
|
||||
|
||||
- SQLite requires `useNullAsDefault: true` in Knex config.
|
||||
- PostgreSQL `INSERT ... RETURNING *` is not supported by SQLite/MySQL — branch on `GetDbType()`.
|
||||
- `knex.fn.now()` is the portable way to set timestamps; never use `NOW()` or `datetime('now')`.
|
||||
- String-based YES/NO flags (not booleans) are the project convention for flag columns.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **YES/NO strings over booleans**: Consistent with existing `is_hidden`, `include_degraded_in_downtime`, etc. Avoids SQLite boolean quirks.
|
||||
- **hasColumn guard in migrations**: Allows re-running migrations safely without failure on already-applied columns.
|
||||
- **Owner flag (`is_owner`)**: Set during migration on the first user by `id ASC`. Only one user should be owner; enforced at application level, not DB constraint.
|
||||
@@ -1,39 +0,0 @@
|
||||
# Group Monitor Cache Flow
|
||||
|
||||
## Overview
|
||||
|
||||
Group monitors aggregate the status of child monitors using weighted scores. The execution involves a cache layer (Redis) that must stay in sync with the database for correct status propagation.
|
||||
|
||||
## Execution Flow
|
||||
|
||||
1. **Cron fires** → `appScheduler` schedules each monitor via BullMQ job scheduler (`monitorSchedulers.ts`)
|
||||
2. **Worker calls** `Minuter(monitor)` in `cron-minute.ts`
|
||||
3. For GROUP monitors, `Minuter` adds a BullMQ **delay** of `executionDelay` ms so child monitors finish first
|
||||
4. **`monitorExecuteQueue`** worker calls `Service.execute()` → `GroupCall.execute()`
|
||||
5. `GroupCall.execute()` reads each child's latest status from **Redis cache** via `GetLastMonitoringValue(tag, fetcher)`
|
||||
|
||||
## Cache Behaviour
|
||||
|
||||
- **Key format:** `kener:cache:{tag}:last_status`
|
||||
- **TTL:** 86400 seconds (1 day)
|
||||
- `getCache()` returns cached value if present — **fetcher is only called on cache miss**
|
||||
- Cache is updated in two places:
|
||||
- `monitorResponseQueue.ts` — after cron-driven monitor execution completes
|
||||
- API PATCH endpoints (`api/v4/monitors/[tag]/data/`) — after external status updates
|
||||
|
||||
## Key Invariant
|
||||
|
||||
Any code path that writes monitoring data to the DB **must** also update the `last_status` cache entry. Otherwise `GroupCall` (and other cache consumers like badge endpoints) will serve stale data.
|
||||
|
||||
## `executionDelay` Purpose
|
||||
|
||||
`executionDelay` is NOT used inside `groupCall.ts`. It is applied in `cron-minute.ts` as BullMQ's `delay` option, deferring the group job so child monitors have time to complete and update the cache before the group aggregates.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/lib/server/services/groupCall.ts` — weighted status aggregation
|
||||
- `src/lib/server/cron-minute.ts` — applies `executionDelay` as BullMQ delay
|
||||
- `src/lib/server/cache/setGet.ts` — `SetLastMonitoringValue` / `GetLastMonitoringValue`
|
||||
- `src/lib/server/queues/monitorResponseQueue.ts` — cache update after cron execution
|
||||
- `src/routes/(api)/api/v4/monitors/[monitor_tag]/data/+server.ts` — range update API
|
||||
- `src/routes/(api)/api/v4/monitors/[monitor_tag]/data/[timestamp]/+server.ts` — single-point update API
|
||||
@@ -24,18 +24,29 @@ src/routes/(api)/api/
|
||||
- **Repository**: `src/lib/server/db/repositories/*.ts` - Database operations
|
||||
- **DbImpl**: `src/lib/server/db/dbimpl.ts` - Bindings for repository methods
|
||||
|
||||
### Current Locals (set by middleware in `hooks.server.ts`)
|
||||
```typescript
|
||||
interface Locals {
|
||||
user?: SessionUser; // Auth session
|
||||
monitor?: MonitorRecordTyped; // /api/monitors/:monitor_tag/*
|
||||
incident?: IncidentRecord; // /api/incidents/:incident_id/*
|
||||
maintenance?: MaintenanceRecord; // /api/maintenances/:maintenance_id/*
|
||||
page?: PageRecord; // /api/pages/:page_path/*
|
||||
}
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Use snake_case for API payloads
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
// Correct
|
||||
interface CreateMonitorRequest {
|
||||
monitor_tag: string;
|
||||
start_date_time: number;
|
||||
duration_seconds: number;
|
||||
}
|
||||
|
||||
// ❌ Wrong
|
||||
// Wrong
|
||||
interface CreateMonitorRequest {
|
||||
monitorTag: string;
|
||||
startDateTime: number;
|
||||
@@ -251,7 +262,7 @@ export const DELETE: RequestHandler = async ({ locals }) => {
|
||||
|
||||
// Delete related records first (cascade)
|
||||
await db.deleteResourceRelatedRecords(resource.id);
|
||||
|
||||
|
||||
// Delete the resource itself
|
||||
await db.deleteResource(resource.id);
|
||||
|
||||
@@ -275,8 +286,8 @@ const normalizedTs = GetMinuteStartTimestampUTC(body.start_date_time);
|
||||
const now = GetNowTimestampUTC();
|
||||
|
||||
// For optional timestamp with fallback
|
||||
const timestamp = body.timestamp !== undefined
|
||||
? GetMinuteStartTimestampUTC(body.timestamp)
|
||||
const timestamp = body.timestamp !== undefined
|
||||
? GetMinuteStartTimestampUTC(body.timestamp)
|
||||
: GetMinuteStartNowTimestampUTC();
|
||||
```
|
||||
|
||||
@@ -300,8 +311,8 @@ if (typeof body.count !== "number" || isNaN(body.count) || body.count <= 0) {
|
||||
```typescript
|
||||
const VALID_STATUSES = ["ACTIVE", "INACTIVE"];
|
||||
if (body.status && !VALID_STATUSES.includes(body.status)) {
|
||||
return json({
|
||||
error: { code: "BAD_REQUEST", message: `status must be one of: ${VALID_STATUSES.join(", ")}` }
|
||||
return json({
|
||||
error: { code: "BAD_REQUEST", message: `status must be one of: ${VALID_STATUSES.join(", ")}` }
|
||||
}, { status: 400 });
|
||||
}
|
||||
```
|
||||
@@ -311,8 +322,8 @@ if (body.status && !VALID_STATUSES.includes(body.status)) {
|
||||
if (body.monitor_tag) {
|
||||
const monitor = await db.getMonitorByTag(body.monitor_tag);
|
||||
if (!monitor) {
|
||||
return json({
|
||||
error: { code: "BAD_REQUEST", message: `Monitor with tag '${body.monitor_tag}' not found` }
|
||||
return json({
|
||||
error: { code: "BAD_REQUEST", message: `Monitor with tag '${body.monitor_tag}' not found` }
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -324,7 +335,7 @@ if (body.items !== undefined) {
|
||||
if (!Array.isArray(body.items)) {
|
||||
return json({ error: { code: "BAD_REQUEST", message: "items must be an array" } }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
for (const item of body.items) {
|
||||
if (!item.tag || typeof item.tag !== "string") {
|
||||
return json({ error: { code: "BAD_REQUEST", message: "Each item must have a valid tag" } }, { status: 400 });
|
||||
|
||||
@@ -2,25 +2,37 @@
|
||||
|
||||
## Project Overview
|
||||
|
||||
Kener is an open-source status page application built with **SvelteKit 2.x** (**Svelte 5**) and **Node.js**, and is migrating to a **TypeScript-first** codebase. It provides real-time monitoring, uptime tracking, incident management, and customizable dashboards.
|
||||
Kener is an open-source status page application built with **SvelteKit 2.x** (**Svelte 5**) and **Node.js/Express**. It is a **TypeScript-first** codebase providing real-time monitoring, uptime tracking, incident management, and customizable dashboards.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Entry Points
|
||||
- **`main.js`** - Production server entry: Express + SvelteKit handler + cron scheduler
|
||||
- **`src/lib/server/startup.js`** - Cron job scheduler for monitors (runs every minute)
|
||||
### Dual Process Model
|
||||
|
||||
In development, `npm run dev` runs two parallel processes:
|
||||
1. **SvelteKit dev server** (`vite dev`) - serves the frontend with HMR
|
||||
2. **Cron scheduler** (`vite-node src/lib/server/startup.ts`) - runs monitor checks, maintenance scheduling, daily cleanup
|
||||
|
||||
In production, **`scripts/main.ts`** is the single entry point: Express server + SvelteKit handler + migrations + seeds + scheduler startup. Built output runs via `node build/main.js`.
|
||||
|
||||
### Route Groups (SvelteKit)
|
||||
- **`(kener)/`** - Public status page routes
|
||||
- **`(manage)/`** - Admin dashboard (requires authentication)
|
||||
- **`(embed)/`** - Embeddable widgets
|
||||
- **`(docs)/`** - Documentation pages
|
||||
- **`(api)/`** - SvelteKit API routes
|
||||
- **`(account)/`** - Account/auth pages
|
||||
- **`(ext)/`** - External integrations
|
||||
- **`(assets)/`** - Asset serving
|
||||
|
||||
### Core Server Components
|
||||
- **`src/lib/server/controllers/controller.js`** - Main business logic (~1700 lines), handles monitors, incidents, auth, email
|
||||
- **`src/lib/server/db/dbimpl.js`** - Database abstraction layer using Knex.js
|
||||
- **`src/lib/server/services/`** - Monitor type implementations: API, Ping, TCP, DNS, SSL, SQL, Heartbeat, GameDig, Group
|
||||
- **`src/lib/server/cron-minute.js`** - Per-monitor cron execution logic
|
||||
- **`src/lib/server/controllers/`** - Domain-split controllers (18 TypeScript files): `apiController.ts`, `incidentController.ts`, `monitorsController.ts`, `maintenanceController.ts`, `pagesController.ts`, `userController.ts`, `dashboardController.ts`, `emailController.ts`, `siteDataController.ts`, `validators.ts`, etc.
|
||||
- **`src/lib/server/db/dbimpl.ts`** - Database abstraction layer using Knex.js with repository composition pattern
|
||||
- **`src/lib/server/db/repositories/`** - Domain-driven repositories: `monitors.ts`, `incidents.ts`, `maintenances.ts`, `pages.ts`, `users.ts`, `alerts.ts`, `monitoring.ts`, `images.ts`, `subscriptionSystem.ts`, `emailTemplateConfig.ts`, `monitorAlertConfig.ts`, `site-data.ts`
|
||||
- **`src/lib/server/services/`** - Monitor type implementations (all TypeScript): `apiCall.ts`, `pingCall.ts`, `tcpCall.ts`, `dnsCall.ts`, `sslCall.ts`, `sqlCall.ts`, `heartbeatCall.ts`, `gamedigCall.ts`, `groupCall.ts`, `grpcCall.ts`, `noneCall.ts`
|
||||
- **`src/lib/server/schedulers/`** - Scheduling via `croner`: `appScheduler.ts`, `monitorSchedulers.ts`, `maintenanceScheduler.ts`, `dailyCleanup.ts`, `shutdown.ts`
|
||||
- **`src/lib/server/queues/`** - Job queues via **BullMQ** + **Redis**: `monitorExecuteQueue.ts`, `monitorResponseQueue.ts`, `alertingQueue.ts`, `emailQueue.ts`, `subscriberQueue.ts`
|
||||
- **`src/lib/server/api-server/`** - Express-side API handlers with file-based routing (directory/method pattern: e.g., `monitor-bar/get.ts`)
|
||||
- **`src/lib/server/cron-minute.ts`** - Per-monitor cron execution logic
|
||||
|
||||
### Database
|
||||
- Supports SQLite (default), PostgreSQL, MySQL via **Knex.js**
|
||||
@@ -28,83 +40,97 @@ Kener is an open-source status page application built with **SvelteKit 2.x** (**
|
||||
- Migrations in `/migrations/`, seeds in `/seeds/`
|
||||
- Run migrations: `npm run migrate` or auto-runs on `npm start`
|
||||
|
||||
### Build System
|
||||
`npm run build` is a two-step process:
|
||||
1. `scripts/build-sveltekit.js` - Vite build of SvelteKit app (optionally with `--with-docs`)
|
||||
2. `scripts/build-server.js` - esbuild bundles `scripts/main.ts` into `build/main.js`
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Start dev server with hot reload + cron scheduler
|
||||
npm run build # Production build
|
||||
npm run preview # Preview production build
|
||||
npm run check # Typecheck + Svelte checks (uses tsconfig)
|
||||
npm run dev # Start dev server (SvelteKit + cron scheduler in parallel)
|
||||
npm run build # Production build (SvelteKit then esbuild server bundle)
|
||||
npm run start # Run production build (node build/main.js)
|
||||
npm run check # Svelte + TypeScript type checking
|
||||
npm run prettify # Format all files with Prettier
|
||||
npm run migrate # Run database migrations via Knex
|
||||
npm run seed # Run database seeds
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Svelte 5 + TypeScript conventions
|
||||
- Prefer **TypeScript** for new/modified code (`.ts`, and `.svelte` with `lang="ts"`).
|
||||
- Prefer **Svelte 5 runes** for component state/effects in new code (e.g. `$state`, `$derived`, `$effect`).
|
||||
- Prefer Svelte 5 props via `$props()` in new components. Keep existing `export let` props where already used to avoid churn.
|
||||
- For SvelteKit route typing, prefer generated `$types` (e.g. `import type { PageServerLoad } from './$types'`).
|
||||
- Avoid packages that hard-require Svelte 4 (they can break or force `--legacy-peer-deps`).
|
||||
- Use **TypeScript** for all code (`.ts`, and `.svelte` with `lang="ts"`).
|
||||
- Use **Svelte 5 runes** (`$state`, `$derived`, `$effect`, `$props()`) in components.
|
||||
- For SvelteKit route typing, use generated `$types` (e.g. `import type { PageServerLoad } from './$types'`).
|
||||
- Avoid packages that hard-require Svelte 4.
|
||||
|
||||
### Monitor Types
|
||||
Defined in `src/lib/server/services/service.js`. Each type has its own implementation file:
|
||||
```javascript
|
||||
// Supported: API, PING, TCP, DNS, GROUP, SSL, SQL, HEARTBEAT, GAMEDIG
|
||||
Defined in `src/lib/server/services/service.ts`. Each type has its own implementation file:
|
||||
```typescript
|
||||
// Supported: API, PING, TCP, DNS, GROUP, SSL, SQL, HEARTBEAT, GAMEDIG, GRPC, NONE
|
||||
```
|
||||
|
||||
### Status Constants
|
||||
Use constants from `src/lib/server/constants`:
|
||||
```javascript
|
||||
import { UP, DOWN, DEGRADED, MAINTENANCE, NO_DATA } from "./constants";
|
||||
Use constants from `src/lib/global-constants.ts`:
|
||||
```typescript
|
||||
// In Svelte/client code:
|
||||
import { UP, DOWN, DEGRADED, MAINTENANCE, NO_DATA } from "$lib/global-constants";
|
||||
|
||||
// In server code (use relative path):
|
||||
import { UP, DOWN, DEGRADED, MAINTENANCE, NO_DATA } from "./global-constants";
|
||||
```
|
||||
|
||||
### API Authentication
|
||||
APIs use Bearer token auth verified via `VerifyAPIKey()`:
|
||||
```javascript
|
||||
import { VerifyAPIKey } from "$lib/server/controllers/controller.js";
|
||||
```typescript
|
||||
import { VerifyAPIKey } from "$lib/server/controllers/apiController";
|
||||
```
|
||||
|
||||
### Database Queries
|
||||
Always use the db singleton, never instantiate Knex directly:
|
||||
```javascript
|
||||
```typescript
|
||||
import db from "$lib/server/db/db";
|
||||
const monitor = await db.getMonitorByTag(tag);
|
||||
```
|
||||
|
||||
### Timestamps
|
||||
All timestamps are **UTC seconds** (not milliseconds). Use helpers from `src/lib/server/tool.js`:
|
||||
```javascript
|
||||
import { GetMinuteStartNowTimestampUTC, GetNowTimestampUTC } from "./tool";
|
||||
All timestamps are **UTC seconds** (not milliseconds). Use helpers from `src/lib/server/tool.ts`:
|
||||
```typescript
|
||||
import { GetMinuteStartTimestampUTC, GetNowTimestampUTC } from "$lib/server/tool";
|
||||
```
|
||||
|
||||
### i18n
|
||||
Locales are in `src/lib/locales/`. Add new translations by creating `{code}.json` and updating `locales.json`.
|
||||
21 locale files in `src/lib/locales/` (en, de, fr, es, hi, ja, ko, zh-CN, zh-TW, pt-BR, ru, etc.). Add new translations by creating `{code}.json` and updating `locales.json`.
|
||||
|
||||
## UI Components
|
||||
|
||||
Uses **shadcn-svelte** components in `src/lib/components/ui/`. Import pattern:
|
||||
```javascript
|
||||
Uses **shadcn-svelte** components in `src/lib/components/ui/` (40+ components). Import pattern:
|
||||
```typescript
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
```
|
||||
|
||||
Styling: **TailwindCSS** with HSL CSS variables for theming (see `tailwind.config.js`).
|
||||
Styling: **Tailwind CSS v4** with CSS-based configuration (no `tailwind.config.js`). Theme uses HSL CSS variables defined in `src/routes/layout.css`.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Required in `.env`:
|
||||
- `KENER_SECRET_KEY` - JWT secret for auth
|
||||
Required:
|
||||
- `KENER_SECRET_KEY` - Secret key for auth
|
||||
- `ORIGIN` - Site URL (e.g., `http://localhost:3000`)
|
||||
- `DATABASE_URL` - Database connection string
|
||||
- `REDIS_URL` - Redis connection string (required for BullMQ job queues)
|
||||
|
||||
Optional:
|
||||
- `DATABASE_URL` - Database connection string (defaults to SQLite)
|
||||
- `KENER_BASE_PATH` - Base path for reverse proxy
|
||||
- `PORT` - Server port (default 3000)
|
||||
- `RESEND_API_KEY` / `RESEND_SENDER_EMAIL` - Email notifications
|
||||
|
||||
## File Conventions
|
||||
|
||||
- Server-only code: `src/lib/server/`
|
||||
- Shared utilities: `src/lib/` (except `server/`)
|
||||
- Route data loading: `+page.server.ts` / `+layout.server.ts` (and client-side `+page.ts` / `+layout.ts` when needed)
|
||||
- Client utilities: `src/lib/client/`
|
||||
- Route data loading: `+page.server.ts` / `+layout.server.ts`
|
||||
- API endpoints: `+server.ts` files returning `json()`
|
||||
|
||||
## Types & Interfaces
|
||||
@@ -112,29 +138,7 @@ Optional:
|
||||
Place types and interfaces in the appropriate folder based on where they are used:
|
||||
|
||||
- **`src/lib/types/`** - Shared types (safe to import from both server and client code). Use for domain models, DTOs, API response types, and anything needed on both sides.
|
||||
- **`src/lib/server/types/`** - Server-only types. Use for DB models, internal service types, auth/session types, and anything that uses `$env/static/private` or Node-only APIs.
|
||||
- **`src/lib/client/types/`** - Client-only types. Use for UI-specific types, component prop types, and anything that relies on browser/DOM APIs.
|
||||
- **`src/lib/server/types/`** - Server-only types (`db.ts`, `auth.ts`, `monitor.ts`, `api-server.ts`). Use for DB models, internal service types, auth/session types.
|
||||
- **`src/lib/client/types/`** - Client-only types (`ui.ts`). Use for UI-specific types, component prop types.
|
||||
|
||||
Always use `import type { ... }` when importing types to avoid accidental runtime imports.
|
||||
|
||||
# Other skills
|
||||
|
||||
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.
|
||||
+2
-1
@@ -19,4 +19,5 @@ config/static/*
|
||||
!config/static/.kener
|
||||
**/*.yaml
|
||||
**/*.yml
|
||||
.github/
|
||||
.github/
|
||||
src/lib/components/ui
|
||||
@@ -38,23 +38,3 @@ When the user asks to write or edit documentation, follow the skill file:
|
||||
- `.claude/skills/documentation-writer/SKILL.md`
|
||||
|
||||
This is mandatory for docs-related tasks. Prioritize short, clear, action-oriented docs and avoid bloat.
|
||||
|
||||
## Code architecture docs skill - Important for all tasks
|
||||
|
||||
Always try to use the code-context skill at the start and end of coding sessions:
|
||||
|
||||
- `.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.
|
||||
|
||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## What is Kener?
|
||||
|
||||
Kener is an open-source status page application built with **SvelteKit 2.x (Svelte 5)** and **Node.js/Express**. It provides real-time monitoring, uptime tracking, incident management, and customizable dashboards. The codebase is migrating to **TypeScript-first**.
|
||||
Kener is an open-source status page application built with **SvelteKit 2.x (Svelte 5)** and **Node.js/Express**. It is a **TypeScript-first** codebase providing real-time monitoring, uptime tracking, incident management, and customizable dashboards.
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -51,7 +51,7 @@ In production, `scripts/main.ts` is the single entry point: Express server + Sve
|
||||
|
||||
Each monitor type has a dedicated implementation in `src/lib/server/services/`:
|
||||
|
||||
- Types: API, Ping, TCP, DNS, SSL, SQL, Heartbeat, GameDig, Group
|
||||
- Types: API, Ping, TCP, DNS, SSL, SQL, Heartbeat, GameDig, Group, gRPC, None
|
||||
- Scheduled via `src/lib/server/schedulers/` using `croner`
|
||||
- Job queues managed with **BullMQ** + **Redis** (`src/lib/server/queues/`)
|
||||
|
||||
@@ -83,21 +83,21 @@ All timestamps are **UTC seconds** (not milliseconds). Use helpers from `src/lib
|
||||
|
||||
### Status Constants
|
||||
|
||||
#### When svelte code
|
||||
Constants are exported as a **default export** from `src/lib/global-constants.ts`:
|
||||
|
||||
```typescript
|
||||
import { UP, DOWN, DEGRADED, MAINTENANCE, NO_DATA } from "$lib/server/global-constants.ts"
|
||||
```
|
||||
// In Svelte/client code or SvelteKit routes:
|
||||
import GC from "$lib/global-constants"
|
||||
// Usage: GC.UP, GC.DOWN, GC.DEGRADED, GC.MAINTENANCE, GC.NO_DATA
|
||||
|
||||
#### When server code use directory traversal
|
||||
|
||||
```typescript
|
||||
import { UP, DOWN, DEGRADED, MAINTENANCE, NO_DATA } from "./global-constants.ts"
|
||||
// In server code (use relative path):
|
||||
import GC from "../../global-constants.js"
|
||||
// Usage: GC.UP, GC.DOWN, etc.
|
||||
```
|
||||
|
||||
### API Authentication
|
||||
|
||||
APIs use Bearer token auth: `import { VerifyAPIKey } from "$lib/server/controllers/controller.js"`
|
||||
APIs use Bearer token auth: `import { VerifyAPIKey } from "$lib/server/controllers/apiController"`
|
||||
|
||||
### Types Location
|
||||
|
||||
@@ -111,8 +111,8 @@ Locale files in `src/lib/locales/`. Add translations by creating `{code}.json` a
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Required: `KENER_SECRET_KEY`, `ORIGIN`, `DATABASE_URL`
|
||||
Optional: `KENER_BASE_PATH`, `PORT` (default 3000), `RESEND_API_KEY`, `RESEND_SENDER_EMAIL`
|
||||
Required: `KENER_SECRET_KEY`, `ORIGIN`, `REDIS_URL`
|
||||
Optional: `DATABASE_URL` (defaults to SQLite), `KENER_BASE_PATH`, `PORT` (default 3000), `RESEND_API_KEY`, `RESEND_SENDER_EMAIL`
|
||||
|
||||
## Skills
|
||||
|
||||
@@ -121,4 +121,3 @@ Read `.claude/skills/` for specialized instructions on:
|
||||
- **svelte-code-writer** - Svelte component creation/editing
|
||||
- **documentation-writer** - Editing docs in `src/routes/(docs)/docs/content/`
|
||||
- **tailwindcss** - Tailwind CSS v4 patterns
|
||||
- **code-context** - Architecture documentation in `.codecontext/`
|
||||
|
||||
@@ -135,6 +135,7 @@ ARG KENER_BASE_PATH=
|
||||
ENV NODE_ENV=production \
|
||||
PORT=${PORT} \
|
||||
KENER_BASE_PATH=${KENER_BASE_PATH} \
|
||||
BODY_SIZE_LIMIT=3M \
|
||||
TZ=UTC \
|
||||
# Required so Node can import .ts migration/seed files at runtime
|
||||
NODE_OPTIONS="--experimental-strip-types"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Default body size limit for SvelteKit adapter-node (512K default is too small for image uploads)
|
||||
export BODY_SIZE_LIMIT="${BODY_SIZE_LIMIT:-3M}"
|
||||
|
||||
# Index documentation into Redis when docs are bundled in the image
|
||||
if [ -f /app/scripts/index-docs.ts ]; then
|
||||
echo "[kener] Indexing documentation into Redis..."
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"entry": ["src/app.html", "build/main.js", "scripts/**/*.{js,ts}", "migrations/**/*.{js,ts}", "seeds/**/*.{js,ts}"],
|
||||
"project": ["src/**/*.{js,ts,svelte}", "scripts/**/*.{js,ts}", "migrations/**/*.{js,ts}", "seeds/**/*.{js,ts}"],
|
||||
"ignore": ["src/lib/components/ui/**"],
|
||||
"ignoreDependencies": [
|
||||
"@babel/runtime",
|
||||
"js-yaml",
|
||||
"mysql2",
|
||||
"node-cache",
|
||||
"pg",
|
||||
"pg-pool",
|
||||
"randomstring",
|
||||
"style-to-object",
|
||||
"lucide-svelte",
|
||||
"marked-gfm-heading-id"
|
||||
],
|
||||
"ignoreExportsUsedInFile": {
|
||||
"interface": true,
|
||||
"type": true
|
||||
},
|
||||
"ignoreBinaries": [],
|
||||
"compilers": {
|
||||
"css": ["postcss"],
|
||||
"svelte": ["svelte"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Migration: Multi-Monitor Alerts
|
||||
*
|
||||
* This migration refactors the monitor alerting system to support many-to-many
|
||||
* relationships between alert configurations and monitors. Previously, each
|
||||
* `monitor_alerts_config` row was tied to exactly one monitor via a `monitor_tag`
|
||||
* foreign key column. This migration:
|
||||
*
|
||||
* 1. Creates a new `monitor_alerts_config_monitors` junction table that links
|
||||
* `monitor_alerts_config` rows to one or more `monitors` rows, enabling a
|
||||
* single alert configuration to fire across multiple monitors.
|
||||
*
|
||||
* 2. Adds a `monitor_tag` column to `monitor_alerts_v2` so that each firing
|
||||
* alert record knows which specific monitor triggered it (important when one
|
||||
* config covers many monitors).
|
||||
*
|
||||
* 3. Migrates existing data: copies every `monitor_alerts_config.monitor_tag`
|
||||
* value into the new junction table and backfills `monitor_alerts_v2.monitor_tag`
|
||||
* from the same source, preserving all historical alert records.
|
||||
*
|
||||
* 4. Removes the one-to-one constraint on `monitor_alerts_config.monitor_tag` by
|
||||
* dropping its foreign key and setting the column nullable (SQLite workaround:
|
||||
* nulls the column directly since SQLite cannot drop foreign key constraints
|
||||
* inline).
|
||||
*
|
||||
* 5. Adds a composite index on `monitor_alerts_v2 (config_id, monitor_tag,
|
||||
* alert_status)` for fast per-monitor alert status lookups.
|
||||
*
|
||||
* The `down` migration reverses these steps: restores the first junction-table
|
||||
* entry back onto `monitor_alerts_config.monitor_tag`, re-adds the foreign key
|
||||
* (non-SQLite), removes the `monitor_tag` column from `monitor_alerts_v2`
|
||||
* (non-SQLite), and drops the junction table.
|
||||
*/
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// Step 1: Create monitor_alerts_config_monitors junction table
|
||||
if (!(await knex.schema.hasTable("monitor_alerts_config_monitors"))) {
|
||||
await knex.schema.createTable("monitor_alerts_config_monitors", (table) => {
|
||||
table.integer("monitor_alerts_id").unsigned().notNullable();
|
||||
table.string("monitor_tag", 255).notNullable();
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
// Composite primary key
|
||||
table.primary(["monitor_alerts_id", "monitor_tag"]);
|
||||
|
||||
// Foreign keys
|
||||
table.foreign("monitor_alerts_id").references("id").inTable("monitor_alerts_config").onDelete("CASCADE");
|
||||
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: Add monitor_tag column to monitor_alerts_v2 for per-monitor alert tracking
|
||||
const hasV2Column = await knex.schema.hasColumn("monitor_alerts_v2", "monitor_tag");
|
||||
if (!hasV2Column) {
|
||||
await knex.schema.alterTable("monitor_alerts_v2", (table) => {
|
||||
table.string("monitor_tag", 255).nullable();
|
||||
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: Migrate existing data from monitor_alerts_config.monitor_tag to junction table
|
||||
// and backfill monitor_alerts_v2.monitor_tag from the same source
|
||||
const existingConfigs = await knex("monitor_alerts_config").whereNotNull("monitor_tag").select("id", "monitor_tag");
|
||||
|
||||
if (existingConfigs.length > 0) {
|
||||
// Build a config_id -> monitor_tag map for backfilling alerts
|
||||
const configTagMap = new Map<number, string>();
|
||||
const inserts = existingConfigs.map((config: { id: number; monitor_tag: string }) => {
|
||||
configTagMap.set(config.id, config.monitor_tag);
|
||||
return {
|
||||
monitor_alerts_id: config.id,
|
||||
monitor_tag: config.monitor_tag,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
};
|
||||
});
|
||||
|
||||
// Batch insert into junction table
|
||||
const chunkSize = 100;
|
||||
for (let i = 0; i < inserts.length; i += chunkSize) {
|
||||
await knex("monitor_alerts_config_monitors").insert(inserts.slice(i, i + chunkSize));
|
||||
}
|
||||
|
||||
// Backfill monitor_tag on existing monitor_alerts_v2 rows
|
||||
const existingAlerts = await knex("monitor_alerts_v2").whereNull("monitor_tag").select("id", "config_id");
|
||||
for (const alert of existingAlerts) {
|
||||
const tag = configTagMap.get(alert.config_id);
|
||||
if (tag) {
|
||||
await knex("monitor_alerts_v2").where({ id: alert.id }).update({ monitor_tag: tag });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Drop foreign key and make monitor_tag nullable on monitor_alerts_config
|
||||
const dbClient = knex.client.config.client;
|
||||
|
||||
if (dbClient === "sqlite3" || dbClient === "better-sqlite3") {
|
||||
// SQLite cannot ALTER COLUMN, so we rebuild the table with monitor_tag nullable
|
||||
await knex.transaction(async (trx) => {
|
||||
await trx.raw("PRAGMA foreign_keys = OFF");
|
||||
await trx.raw("DROP TABLE IF EXISTS monitor_alerts_config_new");
|
||||
await trx.raw(`
|
||||
CREATE TABLE monitor_alerts_config_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
monitor_tag VARCHAR(255),
|
||||
alert_for VARCHAR(50) NOT NULL,
|
||||
alert_value VARCHAR(255) NOT NULL,
|
||||
failure_threshold INTEGER NOT NULL DEFAULT 1,
|
||||
success_threshold INTEGER NOT NULL DEFAULT 1,
|
||||
alert_description TEXT,
|
||||
create_incident VARCHAR(10) NOT NULL DEFAULT 'NO',
|
||||
is_active VARCHAR(10) NOT NULL DEFAULT 'YES',
|
||||
severity VARCHAR(50) NOT NULL DEFAULT 'WARNING',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
await trx.raw(`
|
||||
INSERT INTO monitor_alerts_config_new
|
||||
(id, monitor_tag, alert_for, alert_value, failure_threshold,
|
||||
success_threshold, alert_description, create_incident,
|
||||
is_active, severity, created_at, updated_at)
|
||||
SELECT
|
||||
id, NULL, alert_for, alert_value, failure_threshold,
|
||||
success_threshold, alert_description, create_incident,
|
||||
is_active, severity, created_at, updated_at
|
||||
FROM monitor_alerts_config
|
||||
`);
|
||||
await trx.raw("DROP TABLE monitor_alerts_config");
|
||||
await trx.raw("ALTER TABLE monitor_alerts_config_new RENAME TO monitor_alerts_config");
|
||||
try {
|
||||
await trx.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
|
||||
} catch (_e) {
|
||||
/* index may already exist */
|
||||
}
|
||||
try {
|
||||
await trx.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
|
||||
} catch (_e) {
|
||||
/* index may already exist */
|
||||
}
|
||||
await trx.raw("PRAGMA foreign_keys = ON");
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await knex.schema.alterTable("monitor_alerts_config", (table) => {
|
||||
table.dropForeign(["monitor_tag"]);
|
||||
});
|
||||
} catch (_e) {
|
||||
// Foreign key may not exist or already dropped
|
||||
}
|
||||
|
||||
await knex.schema.alterTable("monitor_alerts_config", (table) => {
|
||||
table.string("monitor_tag", 255).nullable().alter();
|
||||
});
|
||||
|
||||
await knex("monitor_alerts_config").update({ monitor_tag: null });
|
||||
}
|
||||
|
||||
// Step 5: Add composite index on monitor_alerts_v2 for fast lookups
|
||||
try {
|
||||
await knex.raw(
|
||||
"CREATE INDEX idx_monitor_alerts_v2_config_tag_status ON monitor_alerts_v2 (config_id, monitor_tag, alert_status)",
|
||||
);
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
// Step 1: Drop the composite index on monitor_alerts_v2
|
||||
try {
|
||||
await knex.raw("DROP INDEX IF EXISTS idx_monitor_alerts_v2_config_tag_status");
|
||||
} catch (_e) {
|
||||
/* index may not exist */
|
||||
}
|
||||
|
||||
// Step 2: Copy first monitor_tag from junction table back to monitor_alerts_config
|
||||
const configs = await knex("monitor_alerts_config").select("id");
|
||||
|
||||
for (const config of configs) {
|
||||
const firstMonitor = await knex("monitor_alerts_config_monitors").where({ monitor_alerts_id: config.id }).first();
|
||||
|
||||
if (firstMonitor) {
|
||||
await knex("monitor_alerts_config").where({ id: config.id }).update({ monitor_tag: firstMonitor.monitor_tag });
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Delete configs that have no monitors (can't satisfy NOT NULL)
|
||||
await knex("monitor_alerts_config").whereNull("monitor_tag").del();
|
||||
|
||||
// Step 4: Re-add foreign key constraint on monitor_alerts_config (non-SQLite only)
|
||||
const dbClient = knex.client.config.client;
|
||||
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
|
||||
await knex.schema.alterTable("monitor_alerts_config", (table) => {
|
||||
table.string("monitor_tag", 255).notNullable().alter();
|
||||
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
|
||||
});
|
||||
}
|
||||
|
||||
// Step 5: Drop monitor_tag column from monitor_alerts_v2 (non-SQLite only)
|
||||
const hasV2Column = await knex.schema.hasColumn("monitor_alerts_v2", "monitor_tag");
|
||||
if (hasV2Column) {
|
||||
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
|
||||
await knex.schema.alterTable("monitor_alerts_v2", (table) => {
|
||||
table.dropForeign(["monitor_tag"]);
|
||||
table.dropColumn("monitor_tag");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Drop junction table
|
||||
await knex.schema.dropTableIfExists("monitor_alerts_config_monitors");
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Migration: Fix SQLite monitor_alerts_config.monitor_tag NOT NULL constraint
|
||||
*
|
||||
* The earlier migration 20260325120000_multi_monitor_alerts nulled out data in
|
||||
* monitor_alerts_config.monitor_tag for SQLite but could not alter the column
|
||||
* constraint (SQLite doesn't support ALTER COLUMN). This migration recreates
|
||||
* the table with monitor_tag as nullable, preserving all data and indexes.
|
||||
*
|
||||
* Only runs on SQLite/better-sqlite3; other databases already had the column
|
||||
* altered in the previous migration.
|
||||
*/
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const dbClient = knex.client.config.client;
|
||||
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
|
||||
return; // Already handled by 20260325120000_multi_monitor_alerts
|
||||
}
|
||||
|
||||
// Check if monitor_tag is already nullable (migration 1 already rebuilt the table)
|
||||
const tableInfo: Array<{ name: string; notnull: number }> = await knex.raw(
|
||||
"PRAGMA table_info(monitor_alerts_config)",
|
||||
);
|
||||
const monitorTagCol = tableInfo.find((col) => col.name === "monitor_tag");
|
||||
if (monitorTagCol && monitorTagCol.notnull === 0) {
|
||||
return; // Column is already nullable, nothing to do
|
||||
}
|
||||
|
||||
// Column is still NOT NULL — rebuild the table to make it nullable
|
||||
try {
|
||||
await knex.transaction(async (trx) => {
|
||||
await trx.raw("PRAGMA foreign_keys = OFF");
|
||||
await trx.raw("DROP TABLE IF EXISTS monitor_alerts_config_new");
|
||||
await trx.raw(`
|
||||
CREATE TABLE monitor_alerts_config_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
monitor_tag VARCHAR(255),
|
||||
alert_for VARCHAR(50) NOT NULL,
|
||||
alert_value VARCHAR(255) NOT NULL,
|
||||
failure_threshold INTEGER NOT NULL DEFAULT 1,
|
||||
success_threshold INTEGER NOT NULL DEFAULT 1,
|
||||
alert_description TEXT,
|
||||
create_incident VARCHAR(10) NOT NULL DEFAULT 'NO',
|
||||
is_active VARCHAR(10) NOT NULL DEFAULT 'YES',
|
||||
severity VARCHAR(50) NOT NULL DEFAULT 'WARNING',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
await trx.raw(`
|
||||
INSERT INTO monitor_alerts_config_new
|
||||
(id, monitor_tag, alert_for, alert_value, failure_threshold,
|
||||
success_threshold, alert_description, create_incident,
|
||||
is_active, severity, created_at, updated_at)
|
||||
SELECT
|
||||
id, NULL, alert_for, alert_value, failure_threshold,
|
||||
success_threshold, alert_description, create_incident,
|
||||
is_active, severity, created_at, updated_at
|
||||
FROM monitor_alerts_config
|
||||
`);
|
||||
|
||||
await trx.raw("DROP TABLE monitor_alerts_config");
|
||||
await trx.raw("ALTER TABLE monitor_alerts_config_new RENAME TO monitor_alerts_config");
|
||||
|
||||
try {
|
||||
await trx.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
|
||||
} catch (_e) {
|
||||
/* index may already exist */
|
||||
}
|
||||
try {
|
||||
await trx.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
|
||||
} catch (_e) {
|
||||
/* index may already exist */
|
||||
}
|
||||
await trx.raw("PRAGMA foreign_keys = ON");
|
||||
});
|
||||
} catch (e) {
|
||||
await knex.raw("PRAGMA foreign_keys = ON");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const dbClient = knex.client.config.client;
|
||||
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Revert: make monitor_tag NOT NULL again via table rebuild
|
||||
try {
|
||||
await knex.transaction(async (trx) => {
|
||||
await trx.raw("PRAGMA foreign_keys = OFF");
|
||||
await trx.raw(`
|
||||
CREATE TABLE monitor_alerts_config_old (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
monitor_tag VARCHAR(255) NOT NULL,
|
||||
alert_for VARCHAR(50) NOT NULL,
|
||||
alert_value VARCHAR(255) NOT NULL,
|
||||
failure_threshold INTEGER NOT NULL DEFAULT 1,
|
||||
success_threshold INTEGER NOT NULL DEFAULT 1,
|
||||
alert_description TEXT,
|
||||
create_incident VARCHAR(10) NOT NULL DEFAULT 'NO',
|
||||
is_active VARCHAR(10) NOT NULL DEFAULT 'YES',
|
||||
severity VARCHAR(50) NOT NULL DEFAULT 'WARNING',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Only copy rows that have a non-null monitor_tag
|
||||
await trx.raw(`
|
||||
INSERT INTO monitor_alerts_config_old
|
||||
(id, monitor_tag, alert_for, alert_value, failure_threshold,
|
||||
success_threshold, alert_description, create_incident,
|
||||
is_active, severity, created_at, updated_at)
|
||||
SELECT
|
||||
id, monitor_tag, alert_for, alert_value, failure_threshold,
|
||||
success_threshold, alert_description, create_incident,
|
||||
is_active, severity, created_at, updated_at
|
||||
FROM monitor_alerts_config
|
||||
WHERE monitor_tag IS NOT NULL
|
||||
`);
|
||||
|
||||
await trx.raw("DROP TABLE monitor_alerts_config");
|
||||
await trx.raw("ALTER TABLE monitor_alerts_config_old RENAME TO monitor_alerts_config");
|
||||
|
||||
try {
|
||||
await trx.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
|
||||
} catch (_e) {
|
||||
/* index may already exist */
|
||||
}
|
||||
try {
|
||||
await trx.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
|
||||
} catch (_e) {
|
||||
/* index may already exist */
|
||||
}
|
||||
await trx.raw("PRAGMA foreign_keys = ON");
|
||||
});
|
||||
} catch (e) {
|
||||
await knex.raw("PRAGMA foreign_keys = ON");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
Generated
+270
-221
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "kener",
|
||||
"version": "4.0.18",
|
||||
"version": "4.0.21",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kener",
|
||||
"version": "4.0.18",
|
||||
"version": "4.0.21",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
@@ -51,6 +51,7 @@
|
||||
"front-matter": "^4.0.2",
|
||||
"gamedig": "^5.3.2",
|
||||
"glob": "^13.0.6",
|
||||
"heic-convert": "^2.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ioredis": "^5.8.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
@@ -88,7 +89,7 @@
|
||||
"@lucide/svelte": "^0.561.0",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-node": "^5.4.0",
|
||||
"@sveltejs/kit": "^2.48.5",
|
||||
"@sveltejs/kit": "^2.53.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
@@ -97,6 +98,7 @@
|
||||
"@types/d3-shape": "^3.1.8",
|
||||
"@types/dns2": "^2.0.10",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/heic-convert": "^2.1.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/mustache": "^4.2.6",
|
||||
"@types/node": "^25.0.3",
|
||||
@@ -112,7 +114,7 @@
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"svelte": "^5.43.8",
|
||||
"svelte": "^5.53.5",
|
||||
"svelte-awesome-color-picker": "^4.1.0",
|
||||
"svelte-check": "^4.3.4",
|
||||
"svelte-sonner": "^1.0.7",
|
||||
@@ -1790,9 +1792,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
||||
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
|
||||
"integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1803,9 +1805,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz",
|
||||
"integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1816,9 +1818,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz",
|
||||
"integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1829,9 +1831,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz",
|
||||
"integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1842,9 +1844,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz",
|
||||
"integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1855,9 +1857,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz",
|
||||
"integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1868,9 +1870,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz",
|
||||
"integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1881,9 +1883,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz",
|
||||
"integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1894,9 +1896,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1907,9 +1909,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1920,9 +1922,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -1933,9 +1935,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -1946,9 +1948,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1959,9 +1961,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1972,9 +1974,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1985,9 +1987,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1998,9 +2000,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -2011,9 +2013,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2024,9 +2026,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2037,9 +2039,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz",
|
||||
"integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2050,9 +2052,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz",
|
||||
"integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2063,9 +2065,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz",
|
||||
"integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2076,9 +2078,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz",
|
||||
"integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -2089,9 +2091,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2102,9 +2104,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz",
|
||||
"integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2260,9 +2262,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/kit": {
|
||||
"version": "2.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.52.0.tgz",
|
||||
"integrity": "sha512-zG+HmJuSF7eC0e7xt2htlOcEMAdEtlVdb7+gAr+ef08EhtwUsjLxcAwBgUCJY3/5p08OVOxVZti91WfXeuLvsg==",
|
||||
"version": "2.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz",
|
||||
"integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
@@ -2270,12 +2272,11 @@
|
||||
"@types/cookie": "^0.6.0",
|
||||
"acorn": "^8.14.1",
|
||||
"cookie": "^0.6.0",
|
||||
"devalue": "^5.6.2",
|
||||
"devalue": "^5.6.4",
|
||||
"esm-env": "^1.2.2",
|
||||
"kleur": "^4.1.5",
|
||||
"magic-string": "^0.30.5",
|
||||
"mrmime": "^2.0.0",
|
||||
"sade": "^1.8.1",
|
||||
"set-cookie-parser": "^3.0.0",
|
||||
"sirv": "^3.0.0"
|
||||
},
|
||||
@@ -2287,10 +2288,10 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0",
|
||||
"svelte": "^4.0.0 || ^5.0.0-next.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0"
|
||||
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentelemetry/api": {
|
||||
@@ -2756,6 +2757,13 @@
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/heic-convert": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/heic-convert/-/heic-convert-2.1.0.tgz",
|
||||
"integrity": "sha512-Cf5Sdc2Gm2pfZ0uN1zjj35wcf3mF1lJCMIzws5OdJynrdMJRTIRUGa5LegbVg0hatzOPkH2uAf2JRjPYgl9apg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/http-cache-semantics": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||
@@ -2859,8 +2867,7 @@
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@uiw/codemirror-theme-github": {
|
||||
"version": "4.25.4",
|
||||
@@ -2975,9 +2982,9 @@
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
||||
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -3133,10 +3140,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/barse": {
|
||||
"version": "0.4.3",
|
||||
@@ -3325,13 +3335,15 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
@@ -3746,12 +3758,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
@@ -3800,9 +3806,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
@@ -4529,9 +4535,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/devalue": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
|
||||
"integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
|
||||
"version": "5.6.4",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
|
||||
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dns2": {
|
||||
@@ -4582,9 +4588,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -5055,25 +5061,16 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-sha256": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.2.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
|
||||
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
|
||||
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -5082,7 +5079,24 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"strnum": "^2.1.0"
|
||||
"path-expression-matcher": "^1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.5.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz",
|
||||
"integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-builder": "^1.1.4",
|
||||
"path-expression-matcher": "^1.2.0",
|
||||
"strnum": "^2.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
@@ -5568,42 +5582,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/balanced-match": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz",
|
||||
"integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
|
||||
"integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
|
||||
"integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/globalthis": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
|
||||
@@ -5760,6 +5738,32 @@
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/heic-convert": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/heic-convert/-/heic-convert-2.1.0.tgz",
|
||||
"integrity": "sha512-1qDuRvEHifTVAj3pFIgkqGgJIr0M3X7cxEPjEp0oG4mo8GFjq99DpCo8Eg3kg17Cy0MTjxpFdoBHOatj7ZVKtg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"heic-decode": "^2.0.0",
|
||||
"jpeg-js": "^0.4.4",
|
||||
"pngjs": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/heic-decode": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
|
||||
"integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"libheif-js": "^1.19.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.11.1",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||
@@ -6399,6 +6403,12 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jpeg-js": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
|
||||
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
@@ -6638,6 +6648,15 @@
|
||||
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libheif-js": {
|
||||
"version": "1.19.8",
|
||||
"resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.19.8.tgz",
|
||||
"integrity": "sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ==",
|
||||
"license": "LGPL-3.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libmime": {
|
||||
"version": "5.3.7",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
|
||||
@@ -7101,27 +7120,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser": {
|
||||
"version": "3.9.1",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.1.tgz",
|
||||
"integrity": "sha512-6vHZcco3fWsDMkf4Vz9iAfxvwrKNGbHx0dV1RKVphQ/zaNY34Buc7D37LSa09jeSeybWzYcTPjhiZFxzVRJedA==",
|
||||
"version": "3.9.5",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.5.tgz",
|
||||
"integrity": "sha512-i5mXdEqDrh7I095uiQiebOrc00AOIJRLlpze1nBb82oZpI4GaCJIn9ypXR2bqb/Ayr7q+nmT6k11yYU5Age/Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zone-eu/mailsplit": "5.4.8",
|
||||
"encoding-japanese": "2.2.0",
|
||||
"he": "1.2.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"iconv-lite": "0.7.0",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libmime": "5.3.7",
|
||||
"linkify-it": "5.0.0",
|
||||
"nodemailer": "7.0.11",
|
||||
"nodemailer": "8.0.3",
|
||||
"punycode.js": "2.3.1",
|
||||
"tlds": "1.261.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser/node_modules/iconv-lite": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
|
||||
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@@ -7135,9 +7154,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser/node_modules/nodemailer": {
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||
"integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==",
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.3.tgz",
|
||||
"integrity": "sha512-JQNBqvK+bj3NMhUFR3wmCl3SYcOeMotDiwDBvIoCuQdF0PvlIY0BH+FJ2CG7u4cXKPChplE78oowlH/Otsc4ZQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -7308,15 +7327,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"license": "ISC",
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
@@ -7416,6 +7438,7 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
@@ -8040,6 +8063,21 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz",
|
||||
"integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -8253,6 +8291,15 @@
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
|
||||
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -8920,9 +8967,9 @@
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
|
||||
"integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
@@ -8935,31 +8982,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.57.1",
|
||||
"@rollup/rollup-android-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-x64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.57.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.57.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.57.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.57.1",
|
||||
"@rollup/rollup-android-arm-eabi": "4.60.0",
|
||||
"@rollup/rollup-android-arm64": "4.60.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.60.0",
|
||||
"@rollup/rollup-darwin-x64": "4.60.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.60.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.60.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.60.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.60.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.60.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.60.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.60.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.60.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.60.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.60.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.60.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.60.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.60.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.60.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.60.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -9033,6 +9080,7 @@
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
||||
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mri": "^1.1.0"
|
||||
@@ -9790,9 +9838,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
||||
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
|
||||
"integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -9845,20 +9893,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "5.50.1",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.50.1.tgz",
|
||||
"integrity": "sha512-/Jlom4ddkISyVHXpM2O5dXP9pYnaiFrVQzPbIL1/pEoOa77ZunCb6nDgUCTNCQ/X3t64z9ukrK6R+BbB3kPR3A==",
|
||||
"version": "5.54.1",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.54.1.tgz",
|
||||
"integrity": "sha512-ow8tncN097Ty8U1H+C3bM1xNlsCbnO2UZeN0lWBnv8f3jKho7QTTQ2LWbMXrPQDodLjH91n4kpNnLolyRhVE6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
||||
"@types/estree": "^1.0.5",
|
||||
"@types/trusted-types": "^2.0.7",
|
||||
"acorn": "^8.12.1",
|
||||
"aria-query": "^5.3.1",
|
||||
"aria-query": "5.3.1",
|
||||
"axobject-query": "^4.1.0",
|
||||
"clsx": "^2.1.1",
|
||||
"devalue": "^5.6.2",
|
||||
"devalue": "^5.6.4",
|
||||
"esm-env": "^1.2.1",
|
||||
"esrap": "^2.2.2",
|
||||
"is-reference": "^3.0.3",
|
||||
@@ -10389,9 +10438,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz",
|
||||
"integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==",
|
||||
"version": "7.24.5",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz",
|
||||
"integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kener",
|
||||
"version": "4.0.18",
|
||||
"version": "4.0.21",
|
||||
"type": "module",
|
||||
"private": false,
|
||||
"license": "MIT",
|
||||
@@ -55,7 +55,7 @@
|
||||
"@lucide/svelte": "^0.561.0",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-node": "^5.4.0",
|
||||
"@sveltejs/kit": "^2.48.5",
|
||||
"@sveltejs/kit": "^2.53.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
@@ -64,6 +64,7 @@
|
||||
"@types/d3-shape": "^3.1.8",
|
||||
"@types/dns2": "^2.0.10",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/heic-convert": "^2.1.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/mustache": "^4.2.6",
|
||||
"@types/node": "^25.0.3",
|
||||
@@ -79,7 +80,7 @@
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"svelte": "^5.43.8",
|
||||
"svelte": "^5.53.5",
|
||||
"svelte-awesome-color-picker": "^4.1.0",
|
||||
"svelte-check": "^4.3.4",
|
||||
"svelte-sonner": "^1.0.7",
|
||||
@@ -139,6 +140,7 @@
|
||||
"front-matter": "^4.0.2",
|
||||
"gamedig": "^5.3.2",
|
||||
"glob": "^13.0.6",
|
||||
"heic-convert": "^2.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ioredis": "^5.8.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
@@ -170,5 +172,15 @@
|
||||
"style-to-object": "^1.0.14",
|
||||
"svelte-codemirror-editor": "^2.1.0",
|
||||
"vite-plugin-package-version": "^1.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"fast-xml-parser": "^5.5.6",
|
||||
"rollup": "^4.59.0",
|
||||
"undici": "^7.24.0",
|
||||
"minimatch": "^10.2.3",
|
||||
"devalue": "^5.6.4",
|
||||
"dompurify": "^3.3.2",
|
||||
"cookie": "^0.7.0",
|
||||
"mailparser": "^3.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
+82
-74
@@ -1,6 +1,6 @@
|
||||
import { handler } from "../build/handler.js";
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
import express from "express";
|
||||
import Startup from "../src/lib/server/startup.ts";
|
||||
import shutdownSchedulers from "../src/lib/server/schedulers/shutdown.ts";
|
||||
@@ -12,82 +12,90 @@ import knexOb from "../knexfile.js";
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const base = process.env.KENER_BASE_PATH || "";
|
||||
|
||||
const app: any = express();
|
||||
const db = knex(knexOb);
|
||||
async function start() {
|
||||
// Dynamic import so BODY_SIZE_LIMIT from .env is available
|
||||
// before the handler reads it at module top-level
|
||||
const { handler } = await import("../build/handler.js");
|
||||
|
||||
app.get(base + "/healthcheck", (req: any, res: any) => {
|
||||
res.end("ok");
|
||||
});
|
||||
const app: any = express();
|
||||
const db = knex(knexOb);
|
||||
|
||||
app.use(handler);
|
||||
app.get(base + "/healthcheck", (req: any, res: any) => {
|
||||
res.end("ok");
|
||||
});
|
||||
|
||||
//migrations
|
||||
async function runMigrations() {
|
||||
try {
|
||||
// Rename old .js migration entries to .ts in the knex_migrations table
|
||||
// so Knex can find the renamed files on disk
|
||||
const hasTable = await db.schema.hasTable("knex_migrations");
|
||||
if (hasTable) {
|
||||
const oldJsMigrations = await db("knex_migrations").where("name", "like", "%.js");
|
||||
for (const row of oldJsMigrations) {
|
||||
const newName = row.name.replace(/\.js$/, ".ts");
|
||||
await db("knex_migrations").where("id", row.id).update({ name: newName });
|
||||
console.log(`Renamed migration record: ${row.name} -> ${newName}`);
|
||||
}
|
||||
}
|
||||
app.use(handler);
|
||||
|
||||
console.log("Running migrations...");
|
||||
await db.migrate.latest(); // Runs migrations to the latest state
|
||||
console.log("Migrations completed successfully!");
|
||||
} catch (err) {
|
||||
console.error("Error running migrations:", err);
|
||||
}
|
||||
//migrations
|
||||
async function runMigrations() {
|
||||
try {
|
||||
// Rename old .js migration entries to .ts in the knex_migrations table
|
||||
// so Knex can find the renamed files on disk
|
||||
const hasTable = await db.schema.hasTable("knex_migrations");
|
||||
if (hasTable) {
|
||||
const oldJsMigrations = await db("knex_migrations").where("name", "like", "%.js");
|
||||
for (const row of oldJsMigrations) {
|
||||
const newName = row.name.replace(/\.js$/, ".ts");
|
||||
await db("knex_migrations").where("id", row.id).update({ name: newName });
|
||||
console.log(`Renamed migration record: ${row.name} -> ${newName}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Running migrations...");
|
||||
await db.migrate.latest(); // Runs migrations to the latest state
|
||||
console.log("Migrations completed successfully!");
|
||||
} catch (err) {
|
||||
console.error("Error running migrations:", err);
|
||||
}
|
||||
}
|
||||
|
||||
//seed
|
||||
async function runSeed() {
|
||||
try {
|
||||
console.log("Running seed...");
|
||||
await db.seed.run(); // Runs seed to the latest state
|
||||
console.log("Seed completed successfully!");
|
||||
} catch (err) {
|
||||
console.error("Error running seed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
app.listen(PORT, async () => {
|
||||
await runMigrations();
|
||||
await runSeed();
|
||||
await db.destroy();
|
||||
Startup();
|
||||
console.log("Kener is running on port " + PORT + "!");
|
||||
});
|
||||
|
||||
// Graceful shutdown handler
|
||||
async function gracefulShutdown(signal: string) {
|
||||
console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
|
||||
|
||||
try {
|
||||
console.log("Shutting down schedulers...");
|
||||
await shutdownSchedulers();
|
||||
console.log("Schedulers shut down successfully.");
|
||||
|
||||
console.log("Shutting down queues...");
|
||||
await shutdownQueues();
|
||||
console.log("Queues shut down successfully.");
|
||||
|
||||
console.log("Closing database connection...");
|
||||
await dbInstance.close();
|
||||
console.log("Database connection closed successfully.");
|
||||
|
||||
console.log("Graceful shutdown completed.");
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error("Error during graceful shutdown:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle termination signals
|
||||
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
||||
}
|
||||
|
||||
//seed
|
||||
async function runSeed() {
|
||||
try {
|
||||
console.log("Running seed...");
|
||||
await db.seed.run(); // Runs seed to the latest state
|
||||
console.log("Seed completed successfully!");
|
||||
} catch (err) {
|
||||
console.error("Error running seed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
app.listen(PORT, async () => {
|
||||
await runMigrations();
|
||||
await runSeed();
|
||||
await db.destroy();
|
||||
Startup();
|
||||
console.log("Kener is running on port " + PORT + "!");
|
||||
});
|
||||
|
||||
// Graceful shutdown handler
|
||||
async function gracefulShutdown(signal: string) {
|
||||
console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
|
||||
|
||||
try {
|
||||
console.log("Shutting down schedulers...");
|
||||
await shutdownSchedulers();
|
||||
console.log("Schedulers shut down successfully.");
|
||||
|
||||
console.log("Shutting down queues...");
|
||||
await shutdownQueues();
|
||||
console.log("Queues shut down successfully.");
|
||||
|
||||
console.log("Closing database connection...");
|
||||
await dbInstance.close();
|
||||
console.log("Database connection closed successfully.");
|
||||
|
||||
console.log("Graceful shutdown completed.");
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error("Error during graceful shutdown:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle termination signals
|
||||
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
||||
start();
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"ss-shadcn-svelte": {
|
||||
"source": "rajnandan1/such-skills",
|
||||
"sourceType": "github",
|
||||
"computedHash": "0678d0cad0bce1d56731c9613e75f2d274810ad2a17ecea2d21434b6416c90b7"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</Drawer.Trigger>
|
||||
<Drawer.Content class="max-h-[80vh]">
|
||||
<Drawer.Header>
|
||||
<Drawer.Title>{$t("Included Monitors")}</Drawer.Title>
|
||||
<Drawer.Title>{$t("Included Monitors (%count)", { count: String(tags.length) })}</Drawer.Title>
|
||||
</Drawer.Header>
|
||||
<div class="scrollbar-hidden flex flex-col overflow-y-auto px-4 pb-4">
|
||||
{#if tags.length === 0}
|
||||
|
||||
@@ -152,8 +152,7 @@
|
||||
days={days as number}
|
||||
endOfDayTodayAtTz={endOfDayTodayAtTz as number}
|
||||
>
|
||||
{groupChildTags.length}
|
||||
{$t("Included Monitors")}
|
||||
{$t("Included Monitors (%count)", { count: String(groupChildTags.length) })}
|
||||
</GroupMonitorPopover>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -219,7 +219,7 @@
|
||||
{#if groupTags.length > 0}
|
||||
<div class="flex justify-center">
|
||||
<GroupMonitorPopover tags={groupTags} days={selectedDays} {endOfDayTodayAtTz}>
|
||||
{$t("Included Monitors")} ({groupTags.length})
|
||||
{$t("Included Monitors (%count)", { count: String(groupTags.length) })}
|
||||
<ArrowUp class="size-3" />
|
||||
</GroupMonitorPopover>
|
||||
</div>
|
||||
|
||||
+30
-29
@@ -22,39 +22,39 @@
|
||||
"Day": "Den",
|
||||
"Day Uptime": "Denní dostupnost",
|
||||
"Days": "Dní",
|
||||
"Degraded": "Zhoršený",
|
||||
"DEGRADED": "ZHORŠENÝ",
|
||||
"Degraded Performance": "Zhoršený výkon",
|
||||
"Didn't receive the code? Resend": "Nepřišel vám kód? Odeslat znovu",
|
||||
"Down": "Nedostupný",
|
||||
"DOWN": "VÝPADEK",
|
||||
"Degraded": "Omezený",
|
||||
"DEGRADED": "OMEZENÝ",
|
||||
"Degraded Performance": "Snížený výkon",
|
||||
"Didn't receive the code? Resend": "Nepřišel kód? Odeslat znovu",
|
||||
"Down": "Nedostupné",
|
||||
"DOWN": "NEDOSTUPNÉ",
|
||||
"Duration": "Trvání",
|
||||
"Edit Monitor": "Upravit monitor",
|
||||
"Email address": "E-mailová adresa",
|
||||
"Embed Monitor": "Vložit monitor",
|
||||
"Embed this monitor in your website or app": "Vložte tento monitor na svůj web nebo do aplikace",
|
||||
"Embed this monitor in your website or app": "Vložte tento monitor na web nebo do aplikace",
|
||||
"End Time": "Konec",
|
||||
"Enter the verification code sent to your email.": "Zadejte ověřovací kód zaslaný na váš e-mail.",
|
||||
"Enter the verification code sent to your email.": "Zadejte ověřovací kód zaslaný na e-mail",
|
||||
"Events": "Události",
|
||||
"Failed to load data": "Nepodařilo se načíst data",
|
||||
"Failed to load latency data": "Nepodařilo se načíst data latence",
|
||||
"Failed to load status data for this day": "Nepodařilo se načíst data stavu pro tento den",
|
||||
"Failed to send verification code": "Nepodařilo se odeslat ověřovací kód",
|
||||
"Failed to update preference": "Nepodařilo se aktualizovat nastavení",
|
||||
"Failed to update preference": "Nepodařilo se uložit nastavení",
|
||||
"Format": "Formát",
|
||||
"Get badges for this monitor": "Získat odznaky pro tento monitor",
|
||||
"Get notified about incidents and scheduled maintenance.": "Dostávejte upozornění na incidenty a plánovanou údržbu.",
|
||||
"Get notified about incidents and scheduled maintenance.": "Dostávejte upozornění na incidenty a plánovanou údržbu",
|
||||
"Get notified about incidents updates": "Dostávejte upozornění na aktualizace incidentů",
|
||||
"Get notified about scheduled maintenance": "Dostávejte upozornění na plánovanou údržbu",
|
||||
"Home": "Domů",
|
||||
"IDENTIFIED": "IDENTIFIKOVÁNO",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Dopad",
|
||||
"incident": "Incident",
|
||||
"incident": "incident",
|
||||
"Incident": "Incident",
|
||||
"Incident Updates": "Aktualizace incidentů",
|
||||
"Incidents": "Incidenty",
|
||||
"Included Monitors": "Monitorů zahrnuto",
|
||||
"Included Monitors (%count)": "Zahrnuté monitory (%count)",
|
||||
"INVESTIGATING": "VYŠETŘOVÁNÍ",
|
||||
"Last Updated": "Naposledy aktualizováno",
|
||||
"Latency": "Latence",
|
||||
@@ -62,18 +62,18 @@
|
||||
"Latency Over Time": "Latence v čase",
|
||||
"Latency Trend": "Trend latence",
|
||||
"Latest Latency": "Poslední latence",
|
||||
"Latest Status": "Poslední stav",
|
||||
"Latest Status": "Naposledy zjištěný stav",
|
||||
"Light": "Světlý",
|
||||
"Live Status": "Aktuální stav",
|
||||
"Loading your preferences...": "Načítám vaše nastavení...",
|
||||
"maintenance": "Údržba",
|
||||
"Loading your preferences...": "Načítání nastavení...",
|
||||
"maintenance": "údržba",
|
||||
"Maintenance": "Údržba",
|
||||
"MAINTENANCE": "ÚDRŽBA",
|
||||
"Maintenance Updates": "Aktualizace údržby",
|
||||
"Maintenances": "Údrždy",
|
||||
"Maintenances": "Údržby",
|
||||
"Major System Outage": "Závažný výpadek systému",
|
||||
"Manage Site": "Spravovat stránku",
|
||||
"Manage your notification preferences.": "Spravujte svá nastavení oznámení.",
|
||||
"Manage your notification preferences.": "Spravujte nastavení oznámení",
|
||||
"Max Latency": "Max. latence",
|
||||
"maximum": "maximální",
|
||||
"Maximum Latency": "Maximální latence",
|
||||
@@ -82,33 +82,34 @@
|
||||
"Minimum Latency": "Minimální latence",
|
||||
"Minute-by-minute status data for this day": "Minutová data stavu pro tento den",
|
||||
"MONITORING": "MONITOROVÁNÍ",
|
||||
"Network error. Please try again.": "Chyba sítě. Zkuste to prosím znovu.",
|
||||
"Network error. Please try again.": "Chyba sítě. Zkuste to znovu",
|
||||
"No Events in %currentMonth": "V měsíci %currentMonth nejsou plánované žádné události",
|
||||
"No events to show": "Žádné události k zobrazení",
|
||||
"No incidents for this day": "Pro tento den nejsou evidovány žádné incidenty",
|
||||
"No latency data available for this day": "Pro tento den nejsou k dispozici data latence",
|
||||
"No latency data available for this day": "Pro tento den nejsou dostupná data latence",
|
||||
"No maintenances for this day": "Pro tento den není naplánovaná žádná údržba",
|
||||
"No monitors affected": "Žádné zasažené monitory",
|
||||
"No monitors available.": "Žádné dostupné monitory.",
|
||||
"No monitors available.": "Žádné dostupné monitory",
|
||||
"No ongoing maintenances": "Žádná probíhající údržba",
|
||||
"No past maintenances": "Žádná minulá údržba",
|
||||
"No Status Available": "Stav není k dispozici",
|
||||
"No upcoming maintenances": "Žádná nadcházející údržba",
|
||||
"No Updates": "Žádné aktualizace",
|
||||
"No updates yet": "Zatím bez aktualizací",
|
||||
"No updates yet": "Zatím žádné aktualizace",
|
||||
"NO_DATA": "Žádná data",
|
||||
"Notifications": "Oznámení",
|
||||
"One-time": "Jednorázově",
|
||||
"Ongoing": "Probíhající",
|
||||
"ONGOING": "PROBÍHAJÍCÍ",
|
||||
"Ongoing Maintenances": "Probíhající údržby",
|
||||
"Operational": "V provozu",
|
||||
"Partial Degraded Performance": "Částečně zhoršený výkon",
|
||||
"Partial Degraded Performance": "Částečně omezený provoz",
|
||||
"Partial System Outage": "Částečný výpadek systému",
|
||||
"Past": "Minulé",
|
||||
"Per-Minute Status": "Stav po minutách",
|
||||
"Pinging": "Pingování",
|
||||
"Please enter a valid email address": "Zadejte platnou e-mailovou adresu",
|
||||
"Please enter the 6-digit verification code": "Zadejte prosím 6místný ověřovací kód",
|
||||
"Please enter the 6-digit verification code": "Zadejte 6místný ověřovací kód",
|
||||
"Read less": "Zobrazit méně",
|
||||
"Read more": "Zobrazit více",
|
||||
"READY": "PŘIPRAVENO",
|
||||
@@ -120,20 +121,20 @@
|
||||
"Scheduled Windows": "Naplánované úlohy",
|
||||
"Script": "Skript",
|
||||
"Select Language": "Vyberte jazyk",
|
||||
"Select latency metric to display": "Vyberte metriku latence k zobrazení",
|
||||
"Select latency metric to display": "Vyberte metriku latence",
|
||||
"Select Range": "Vyberte rozsah",
|
||||
"Sending...": "Odesílání...",
|
||||
"Standard": "Standardní",
|
||||
"Start Time": "Začátek",
|
||||
"Status": "Stav",
|
||||
"Status Badge": "Odznak stavu",
|
||||
"Status Embed": "Status pro vložení",
|
||||
"Status Embed": "Stav pro vložení",
|
||||
"Status history and latency trend": "Historie stavu a trend latence",
|
||||
"Subscribe": "Odebírat",
|
||||
"Subscribe": "Přihlásit se k odběru",
|
||||
"Subscribe to Updates": "Odebírat aktualizace",
|
||||
"Theme": "Motiv",
|
||||
"There are no incidents or maintenances scheduled for this month.": "Na tento měsíc nejsou naplánované incidenty ani údržba.",
|
||||
"There are no ongoing incidents or maintenance events.": "V tuto chvíli neprobíhají žádné incidenty ani údržba.",
|
||||
"There are no incidents or maintenances scheduled for this month.": "Na tento měsíc nejsou naplánované žádné incidenty ani údržby",
|
||||
"There are no ongoing incidents or maintenance events.": "V tuto chvíli neprobíhají žádné incidenty ani údržba",
|
||||
"Timeline": "Časová osa",
|
||||
"Total Incidents": "Celkem incidentů",
|
||||
"Total Maintenances": "Celkem údržeb",
|
||||
@@ -151,6 +152,6 @@
|
||||
"Verification failed": "Ověření se nezdařilo",
|
||||
"Verify": "Ověřit",
|
||||
"Verifying": "Ověřování",
|
||||
"We sent a 6-digit code to": "Poslali jsme 6místný kód na"
|
||||
"We sent a 6-digit code to": "Odeslali jsme 6místný kód na"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"incident": "Vorfall",
|
||||
"Incident Updates": "Vorfallaktualisierungen",
|
||||
"Incidents": "Vorfälle",
|
||||
"Included Monitors": "Enthaltene Monitore",
|
||||
"Included Monitors (%count)": "Enthaltene Monitore (%count)",
|
||||
"INVESTIGATING": "WIRD UNTERSUCHT",
|
||||
"Last Updated": "Zuletzt aktualisiert",
|
||||
"Latency": "Latenz",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"incident": "Hændelse",
|
||||
"Incident Updates": "Hændelsesopdateringer",
|
||||
"Incidents": "Hændelser",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Sidst opdateret",
|
||||
"Latency": "Latency",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"incident": "Incident",
|
||||
"Incident Updates": "Incident Updates",
|
||||
"Incidents": "Incidents",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Last Updated",
|
||||
"Latency": "Latency",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"incident": "Incidente",
|
||||
"Incident Updates": "Actualizaciones de incidentes",
|
||||
"Incidents": "Incidentes",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Última actualización",
|
||||
"Latency": "Estado latente",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"incident": "رخداد",
|
||||
"Incident Updates": "به روز رسانی حادثه",
|
||||
"Incidents": "حوادث",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "آخرین به روز رسانی",
|
||||
"Latency": "تأخیر",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"incident": "Incident",
|
||||
"Incident Updates": "Mises à jour des incidents",
|
||||
"Incidents": "Incidents",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Dernière mise à jour",
|
||||
"Latency": "Latence",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"incident": "घटना",
|
||||
"Incident Updates": "घटना अपडेट",
|
||||
"Incidents": "घटनाएँ",
|
||||
"Included Monitors": "शामिल मॉनिटर",
|
||||
"Included Monitors (%count)": "शामिल मॉनिटर (%count)",
|
||||
"INVESTIGATING": "जाँच कर रहे हैं",
|
||||
"Last Updated": "अंतिम अपडेट",
|
||||
"Latency": "लेटेंसी",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"incident": "Incidente",
|
||||
"Incident Updates": "Aggiornamenti sugli incidenti",
|
||||
"Incidents": "Incidenti",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Ultimo aggiornamento",
|
||||
"Latency": "Latenza",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"incident": "インシデント",
|
||||
"Incident Updates": "インシデントの最新情報",
|
||||
"Incidents": "事件",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "最終更新日",
|
||||
"Latency": "レイテンシー",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"incident": "인시던트",
|
||||
"Incident Updates": "사고 업데이트",
|
||||
"Incidents": "사건",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "마지막 업데이트",
|
||||
"Latency": "숨어 있음",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"incident": "Hendelse",
|
||||
"Incident Updates": "Hendelsesoppdateringer",
|
||||
"Incidents": "Hendelser",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Sist oppdatert",
|
||||
"Latency": "Latens",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"incident": "Incident",
|
||||
"Incident Updates": "Incidentupdates",
|
||||
"Incidents": "Incidenten",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Laatst bijgewerkt",
|
||||
"Latency": "Latentie",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"incident": "Incydent",
|
||||
"Incident Updates": "Aktualizacje incydentów",
|
||||
"Incidents": "Incydenty",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Ostatnia aktualizacja",
|
||||
"Latency": "Utajenie",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"incident": "Incidente",
|
||||
"Incident Updates": "Atualizações de incidentes",
|
||||
"Incidents": "Incidentes",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Última atualização",
|
||||
"Latency": "Latência",
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"incident": "Инцидент",
|
||||
"Incident Updates": "Обновления инцидентов",
|
||||
"Incidents": "Инциденты",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Последнее обновление",
|
||||
"Latency": "Задержка",
|
||||
|
||||
+35
-34
@@ -6,7 +6,7 @@
|
||||
"30 Days": "30 dní",
|
||||
"7 Days": "7 dní",
|
||||
"90 Days": "90 dní",
|
||||
"Affected Monitors (%count)": "Ovládacie monitory (%count)",
|
||||
"Affected Monitors (%count)": "Ovplyvnené monitory (%count)",
|
||||
"All Systems Operational": "Všetky systémy sú v prevádzke",
|
||||
"average": "priemerná",
|
||||
"Average Latency": "Priemerná latencia",
|
||||
@@ -22,58 +22,58 @@
|
||||
"Day": "Deň",
|
||||
"Day Uptime": "Denná dostupnosť",
|
||||
"Days": "Dní",
|
||||
"Degraded": "Zhoršený",
|
||||
"DEGRADED": "ZHORŠENÝ",
|
||||
"Degraded Performance": "Zhoršený výkon",
|
||||
"Didn't receive the code? Resend": "Nedostali ste kód? Odoslať znova",
|
||||
"Down": "Nedostupný",
|
||||
"DOWN": "VÝPADOK",
|
||||
"Degraded": "Obmedzený",
|
||||
"DEGRADED": "OBMEDZENÝ",
|
||||
"Degraded Performance": "Znížený výkon",
|
||||
"Didn't receive the code? Resend": "Neprišiel kód? Odoslať znova",
|
||||
"Down": "Nedostupné",
|
||||
"DOWN": "NEDOSTUPNÉ",
|
||||
"Duration": "Trvanie",
|
||||
"Edit Monitor": "Upraviť monitor",
|
||||
"Email address": "E-mailová adresa",
|
||||
"Embed Monitor": "Vložiť monitor",
|
||||
"Embed this monitor in your website or app": "Vložte tento monitor na svoj web alebo do aplikácie",
|
||||
"Embed this monitor in your website or app": "Vložte tento monitor na web alebo do aplikácie",
|
||||
"End Time": "Koniec",
|
||||
"Enter the verification code sent to your email.": "Zadajte overovací kód zaslaný na váš e-mail.",
|
||||
"Enter the verification code sent to your email.": "Zadajte overovací kód zaslaný na e-mail",
|
||||
"Events": "Udalosti",
|
||||
"Failed to load data": "Nepodarilo sa načítať dáta",
|
||||
"Failed to load latency data": "Nepodarilo sa načítať dáta latencie",
|
||||
"Failed to load status data for this day": "Nepodarilo sa načítať dáta stavu pre tento deň",
|
||||
"Failed to load status data for this day": "Nepodarilo sa načítať stavové dáta pre tento deň",
|
||||
"Failed to send verification code": "Nepodarilo sa odoslať overovací kód",
|
||||
"Failed to update preference": "Nepodarilo sa aktualizovať nastavenia",
|
||||
"Failed to update preference": "Nepodarilo sa uložiť nastavenie",
|
||||
"Format": "Formát",
|
||||
"Get badges for this monitor": "Získať odznaky pre tento monitor",
|
||||
"Get notified about incidents and scheduled maintenance.": "Dostávajte upozornenia na incidenty a plánovanú údržbu.",
|
||||
"Get notified about incidents and scheduled maintenance.": "Dostávajte upozornenia na incidenty a plánovanú údržbu",
|
||||
"Get notified about incidents updates": "Dostávajte upozornenia na aktualizácie incidentov",
|
||||
"Get notified about scheduled maintenance": "Dostávajte upozornenia na plánovanú údržbu",
|
||||
"Home": "Domov",
|
||||
"IDENTIFIED": "IDENTIFIKOVANÉ",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Dopad",
|
||||
"incident": "Incident",
|
||||
"incident": "incident",
|
||||
"Incident": "Incident",
|
||||
"Incident Updates": "Aktualizácie incidentov",
|
||||
"Incidents": "Incidenty",
|
||||
"Included Monitors": "Monitorov zahrnuté",
|
||||
"INVESTIGATING": "VYŠETROVANIE",
|
||||
"Included Monitors (%count)": "Zahrnuté monitory (%count)",
|
||||
"INVESTIGATING": "PREBIEHA VYŠETROVANIE",
|
||||
"Last Updated": "Naposledy aktualizované",
|
||||
"Latency": "Latencia",
|
||||
"Latency Embed": "Vložená latencia",
|
||||
"Latency Over Time": "Latencia v čase",
|
||||
"Latency Trend": "Trend latencie",
|
||||
"Latest Latency": "Posledná latencia",
|
||||
"Latest Status": "Posledný stav",
|
||||
"Latest Status": "Naposledy zistený stav",
|
||||
"Light": "Svetlý",
|
||||
"Live Status": "Aktuálny stav",
|
||||
"Loading your preferences...": "Načítavam vaše nastavenia...",
|
||||
"maintenance": "Údržba",
|
||||
"Loading your preferences...": "Načítavanie nastavení...",
|
||||
"maintenance": "údržba",
|
||||
"Maintenance": "Údržba",
|
||||
"MAINTENANCE": "ÚDRŽBA",
|
||||
"Maintenance Updates": "Aktualizácie údržby",
|
||||
"Maintenances": "Údržby",
|
||||
"Major System Outage": "Závažný výpadok systému",
|
||||
"Manage Site": "Spravovať stránku",
|
||||
"Manage your notification preferences.": "Spravujte svoje nastavenia oznámení.",
|
||||
"Manage your notification preferences.": "Spravujte nastavenia upozornení",
|
||||
"Max Latency": "Max. latencia",
|
||||
"maximum": "maximálna",
|
||||
"Maximum Latency": "Maximálna latencia",
|
||||
@@ -82,27 +82,28 @@
|
||||
"Minimum Latency": "Minimálna latencia",
|
||||
"Minute-by-minute status data for this day": "Minútové dáta stavu pre tento deň",
|
||||
"MONITORING": "MONITOROVANIE",
|
||||
"Network error. Please try again.": "Chyba siete. Skúste to znova.",
|
||||
"No Events in %currentMonth": "V mesiaci %currentMonth nie sú plánované žiadne udalosti",
|
||||
"Network error. Please try again.": "Chyba siete. Skúste to znova",
|
||||
"No Events in %currentMonth": "V mesiaci %currentMonth nie sú žiadne udalosti",
|
||||
"No events to show": "Žiadne udalosti na zobrazenie",
|
||||
"No incidents for this day": "Pre tento deň nie sú evidované žiadne incidenty",
|
||||
"No latency data available for this day": "Pre tento deň nie sú k dispozícii dáta latencie",
|
||||
"No latency data available for this day": "Pre tento deň nie sú dostupné dáta latencie",
|
||||
"No maintenances for this day": "Pre tento deň nie je naplánovaná žiadna údržba",
|
||||
"No monitors affected": "Žiadne zasiahnuté monitory",
|
||||
"No monitors available.": "Žiadne dostupné monitory.",
|
||||
"No monitors affected": "Žiadne ovplyvnené monitory",
|
||||
"No monitors available.": "Žiadne dostupné monitory",
|
||||
"No ongoing maintenances": "Žiadna prebiehajúca údržba",
|
||||
"No past maintenances": "Žiadna minula údržba",
|
||||
"No past maintenances": "Žiadna minulá údržba",
|
||||
"No Status Available": "Stav nie je k dispozícii",
|
||||
"No upcoming maintenances": "Žiadna nadchádzajúca údržba",
|
||||
"No Updates": "Žiadne aktualizácie",
|
||||
"No updates yet": "Zatiaľ bez aktualizácií",
|
||||
"No updates yet": "Zatiaľ žiadne aktualizácie",
|
||||
"NO_DATA": "Žiadne dáta",
|
||||
"Notifications": "Oznámenia",
|
||||
"Notifications": "Upozornenia",
|
||||
"One-time": "Jednorazovo",
|
||||
"Ongoing": "Prebiehajúce",
|
||||
"ONGOING": "PREBIEHAJÚCE",
|
||||
"Ongoing Maintenances": "Prebiehajúce údržby",
|
||||
"Operational": "V prevádzke",
|
||||
"Partial Degraded Performance": "Čiastočne zhoršený výkon",
|
||||
"Partial Degraded Performance": "Čiastočne obmedzený výkon",
|
||||
"Partial System Outage": "Čiastočný výpadok systému",
|
||||
"Past": "Minulé",
|
||||
"Per-Minute Status": "Stav po minútach",
|
||||
@@ -120,20 +121,20 @@
|
||||
"Scheduled Windows": "Naplánované úlohy",
|
||||
"Script": "Skript",
|
||||
"Select Language": "Vyberte jazyk",
|
||||
"Select latency metric to display": "Vyberte metriku latencie na zobrazenie",
|
||||
"Select latency metric to display": "Vyberte metriku latencie",
|
||||
"Select Range": "Vyberte rozsah",
|
||||
"Sending...": "Odosielanie...",
|
||||
"Standard": "Štandardný",
|
||||
"Start Time": "Začiatok",
|
||||
"Status": "Stav",
|
||||
"Status Badge": "Odznak stavu",
|
||||
"Status Embed": "Status na vloženie",
|
||||
"Status Embed": "Stav na vloženie",
|
||||
"Status history and latency trend": "História stavu a trend latencie",
|
||||
"Subscribe": "Odoberať",
|
||||
"Subscribe": "Prihlásiť sa na odber",
|
||||
"Subscribe to Updates": "Odoberať aktualizácie",
|
||||
"Theme": "Motív",
|
||||
"There are no incidents or maintenances scheduled for this month.": "Na tento mesiac nie sú naplánované incidenty ani údržba.",
|
||||
"There are no ongoing incidents or maintenance events.": "V tejto chvíli neprebiehajú žiadne incidenty ani údržba.",
|
||||
"There are no incidents or maintenances scheduled for this month.": "Na tento mesiac nie sú naplánované žiadne incidenty ani údržby",
|
||||
"There are no ongoing incidents or maintenance events.": "Momentálne neprebiehajú žiadne incidenty ani údržba",
|
||||
"Timeline": "Časová os",
|
||||
"Total Incidents": "Celkom incidentov",
|
||||
"Total Maintenances": "Celkom údržieb",
|
||||
@@ -151,6 +152,6 @@
|
||||
"Verification failed": "Overenie zlyhalo",
|
||||
"Verify": "Overiť",
|
||||
"Verifying": "Overovanie",
|
||||
"We sent a 6-digit code to": "Poslali sme 6-miestny kód na"
|
||||
"We sent a 6-digit code to": "Odoslali sme 6-miestny kód na"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"incident": "Olay",
|
||||
"Incident Updates": "Olay Güncellemeleri",
|
||||
"Incidents": "Olaylar",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Son Güncelleme",
|
||||
"Latency": "Gecikme",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"incident": "Sự cố",
|
||||
"Incident Updates": "Cập nhật sự cố",
|
||||
"Incidents": "Sự cố",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Cập nhật lần cuối",
|
||||
"Latency": "Độ trễ",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"incident": "事件",
|
||||
"Incident Updates": "事件更新",
|
||||
"Incidents": "事件",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "最后更新",
|
||||
"Latency": "延迟",
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"Impact": "影響",
|
||||
"Incident Updates": "事件更新",
|
||||
"Incidents": "事件",
|
||||
"Included Monitors": "包含的監控項目",
|
||||
"Included Monitors (%count)": "包含的監控項目 (%count)",
|
||||
"INVESTIGATING": "調查中",
|
||||
"Last Updated": "最後更新",
|
||||
"Latency": "延遲",
|
||||
|
||||
@@ -159,9 +159,13 @@ export interface PageDashboardData {
|
||||
pageStatus: { statusSummary: string; statusClass: string };
|
||||
ongoingIncidents: IncidentForMonitorListWithComments[];
|
||||
ongoingMaintenances: MaintenanceEventsMonitorList[];
|
||||
upcomingMaintenances: MaintenanceEventsMonitorList[];
|
||||
monitorTags: string[];
|
||||
monitorGroupMembersByTag: Record<string, string[]>;
|
||||
pageDetails: PageRecordTyped;
|
||||
socialPagePreviewImage?: string;
|
||||
metaPageTitle?: string;
|
||||
metaPageDescription?: string;
|
||||
}
|
||||
|
||||
const BuildPageStatus = (latestData: Array<{ status?: string | null; latency?: number | null }>, nowTs: number) => {
|
||||
@@ -342,19 +346,39 @@ export const GetPageDashboardData = async (
|
||||
updated_at: pageDetails.updated_at,
|
||||
};
|
||||
|
||||
let socialPagePreviewImage: string | undefined = layoutData.socialPreviewImage;
|
||||
let metaPageTitle: string | undefined = layoutData.metaSiteTitle;
|
||||
let metaPageDescription: string | undefined = layoutData.metaSiteDescription;
|
||||
if (!!pageDetails.page_settings_json) {
|
||||
try {
|
||||
const pageSettings = JSON.parse(pageDetails.page_settings_json);
|
||||
if (pageSettings) {
|
||||
socialPagePreviewImage = pageSettings.socialPagePreviewImage || layoutData.socialPreviewImage;
|
||||
metaPageTitle = pageSettings.metaPageTitle || layoutData.metaSiteTitle;
|
||||
metaPageDescription = pageSettings.metaPageDescription || layoutData.metaSiteDescription;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore JSON parsing errors and fallback to layout data or defaults
|
||||
}
|
||||
}
|
||||
|
||||
if (monitorTags.length === 0) {
|
||||
return {
|
||||
pageStatus: BuildPageStatus([], nowTs),
|
||||
ongoingIncidents: [],
|
||||
ongoingMaintenances: [],
|
||||
upcomingMaintenances: [],
|
||||
monitorTags,
|
||||
monitorGroupMembersByTag: {},
|
||||
pageDetails: pageDetailsTyped,
|
||||
socialPagePreviewImage,
|
||||
metaPageTitle,
|
||||
metaPageDescription,
|
||||
};
|
||||
}
|
||||
const eventSettings = layoutData.eventDisplaySettings;
|
||||
// Fetch all dashboard data in parallel (respecting feature toggles)
|
||||
const [latestData, parsedMonitors, ongoingIncidents, ongoingMaintenances] = await Promise.all([
|
||||
const [latestData, parsedMonitors, ongoingIncidents, ongoingMaintenances, upcomingMaintenances] = await Promise.all([
|
||||
GetLatestMonitoringDataAllActive(monitorTags),
|
||||
GetMonitorsParsed({ tags: monitorTags, status: "ACTIVE", is_hidden: "NO" }),
|
||||
eventSettings.incidents.enabled && eventSettings.incidents.ongoing.show
|
||||
@@ -363,6 +387,13 @@ export const GetPageDashboardData = async (
|
||||
eventSettings.maintenances.enabled && eventSettings.maintenances.ongoing.show
|
||||
? GetOngoingMaintenances(monitorTags, nowTs)
|
||||
: Promise.resolve([] as MaintenanceEventsMonitorList[]),
|
||||
eventSettings.maintenances.enabled && eventSettings.maintenances.upcoming.show
|
||||
? GetUpcomingMaintenanceEventsForMonitorList(
|
||||
monitorTags,
|
||||
eventSettings.maintenances.upcoming.maxCount,
|
||||
eventSettings.maintenances.upcoming.daysInFuture,
|
||||
)
|
||||
: Promise.resolve([] as MaintenanceEventsMonitorList[]),
|
||||
]);
|
||||
|
||||
const pageStatus = BuildPageStatus(latestData, nowTs);
|
||||
@@ -381,8 +412,12 @@ export const GetPageDashboardData = async (
|
||||
pageStatus,
|
||||
ongoingIncidents,
|
||||
ongoingMaintenances,
|
||||
upcomingMaintenances,
|
||||
monitorTags,
|
||||
monitorGroupMembersByTag,
|
||||
pageDetails: pageDetailsTyped,
|
||||
socialPagePreviewImage,
|
||||
metaPageTitle,
|
||||
metaPageDescription,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -71,6 +71,8 @@ export interface LayoutServerData {
|
||||
customCSS?: string;
|
||||
globalPageVisibilitySettings: GlobalPageVisibilitySettings;
|
||||
dateAndTimeFormat: SiteDateTimeFormat;
|
||||
metaSiteTitle?: string;
|
||||
metaSiteDescription?: string;
|
||||
}
|
||||
|
||||
export async function GetLayoutServerData(cookies: Cookies, request: Request): Promise<LayoutServerData> {
|
||||
@@ -139,5 +141,7 @@ export async function GetLayoutServerData(cookies: Cookies, request: Request): P
|
||||
customCSS: siteData.customCSS,
|
||||
globalPageVisibilitySettings: siteData.globalPageVisibilitySettings || seedSiteData.globalPageVisibilitySettings,
|
||||
dateAndTimeFormat: siteData.dateAndTimeFormat || seedSiteData.dateAndTimeFormat,
|
||||
metaSiteTitle: siteData.metaSiteTitle,
|
||||
metaSiteDescription: siteData.metaSiteDescription,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { maintenanceToVariables, siteDataToVariables } from "../notification/not
|
||||
import { GetAllSiteData } from "./controller.js";
|
||||
import subscriberQueue from "../queues/subscriberQueue.js";
|
||||
import GC from "../../global-constants";
|
||||
import seedSiteData from "../db/seedSiteData.js";
|
||||
|
||||
// ============ Input Interfaces ============
|
||||
|
||||
@@ -78,6 +79,7 @@ export interface MaintenanceWithEvents extends MaintenanceWithMonitors {
|
||||
export function determineEventStatus(
|
||||
eventStartTimestamp: number,
|
||||
eventEndTimestamp: number,
|
||||
reminderBufferSeconds: number = 3600,
|
||||
): "SCHEDULED" | "READY" | "ONGOING" | "COMPLETED" | "CANCELLED" {
|
||||
const nowTimestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
@@ -87,38 +89,90 @@ export function determineEventStatus(
|
||||
if (nowTimestamp >= eventStartTimestamp) {
|
||||
return "ONGOING";
|
||||
}
|
||||
// 60 minutes = 3600 seconds
|
||||
if (eventStartTimestamp - nowTimestamp <= 3600) {
|
||||
if (eventStartTimestamp - nowTimestamp <= reminderBufferSeconds) {
|
||||
return "READY";
|
||||
}
|
||||
return "SCHEDULED";
|
||||
}
|
||||
|
||||
// ============ Helper to create a maintenance event with notification ============
|
||||
|
||||
export const CreateMaintenanceEventWithNotification = async (
|
||||
maintenance_id: number,
|
||||
start_date_time: number,
|
||||
end_date_time: number,
|
||||
title: string,
|
||||
description: string | null,
|
||||
): Promise<MaintenanceEventRecord> => {
|
||||
const siteData = await GetAllSiteData();
|
||||
const notificationSettings =
|
||||
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
|
||||
const reminderBufferSeconds = notificationSettings.reminder_buffer_hours * 3600;
|
||||
|
||||
const event = await db.createMaintenanceEvent({
|
||||
maintenance_id,
|
||||
start_date_time,
|
||||
end_date_time,
|
||||
status: determineEventStatus(start_date_time, end_date_time, reminderBufferSeconds),
|
||||
});
|
||||
|
||||
try {
|
||||
if (notificationSettings.event_types.created) {
|
||||
const siteVars = siteDataToVariables(siteData);
|
||||
const siteUrl = siteVars.site_url;
|
||||
const monitors = await db.getMonitorsByMaintenanceId(maintenance_id);
|
||||
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
|
||||
const eventDetailed: MaintenanceEventRecordDetailed = {
|
||||
id: event.id,
|
||||
maintenance_id,
|
||||
start_date_time,
|
||||
end_date_time,
|
||||
status: event.status as MaintenanceEventRecordDetailed["status"],
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
title,
|
||||
description,
|
||||
};
|
||||
const update = maintenanceToVariables(
|
||||
eventDetailed,
|
||||
monitorNames,
|
||||
"**has been created**",
|
||||
"created",
|
||||
"Maintenance Created",
|
||||
siteUrl,
|
||||
);
|
||||
await subscriberQueue.push(update);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error sending created notification for maintenance event ${event.id}:`, err);
|
||||
}
|
||||
|
||||
return event;
|
||||
};
|
||||
|
||||
// ============ Helper to generate upcoming events from RRULE ============
|
||||
|
||||
/**
|
||||
* Generate maintenance events for the next N days based on the RRULE
|
||||
* Generate maintenance events based on the RRULE
|
||||
* @param maintenance_id - The maintenance record ID
|
||||
* @param start_date_time - Unix timestamp for the DTSTART
|
||||
* @param rrule - The RRULE string (e.g., FREQ=WEEKLY;BYDAY=SU)
|
||||
* @param duration_seconds - Duration of each maintenance window
|
||||
* @param daysAhead - Number of days to look ahead (default 7)
|
||||
* @param count - Maximum number of events to create (default 1)
|
||||
*/
|
||||
export const GenerateMaintenanceEvents = async (
|
||||
maintenance_id: number,
|
||||
start_date_time: number,
|
||||
rrule: string,
|
||||
duration_seconds: number,
|
||||
daysAhead: number = 7,
|
||||
count: number = 1,
|
||||
): Promise<MaintenanceEventRecord[]> => {
|
||||
const createdEvents: MaintenanceEventRecord[] = [];
|
||||
|
||||
// Convert start timestamp to Date (UTC)
|
||||
const dtstart = new Date(start_date_time * 1000);
|
||||
|
||||
// Define the window to generate events
|
||||
const now = new Date();
|
||||
const windowEnd = addDays(now, daysAhead);
|
||||
|
||||
try {
|
||||
// Build the full RRULE string with DTSTART
|
||||
@@ -127,22 +181,30 @@ export const GenerateMaintenanceEvents = async (
|
||||
// Parse the RRULE
|
||||
const rule = rrulestr(fullRrule);
|
||||
|
||||
// Get occurrences between now and window end
|
||||
// For one-time (COUNT=1), we use dtstart as the reference
|
||||
let occurrences: Date[];
|
||||
// Get occurrences based on count
|
||||
let occurrences: Date[] = [];
|
||||
|
||||
if (rrule.includes("COUNT=1")) {
|
||||
// One-time maintenance: only create event if start_date_time is in the future or within window
|
||||
if (dtstart >= now || (dtstart <= windowEnd && dtstart >= addDays(now, -1))) {
|
||||
// One-time maintenance: only create event if start_date_time is recent or in the future
|
||||
if (dtstart >= now || dtstart >= addDays(now, -1)) {
|
||||
occurrences = [dtstart];
|
||||
} else {
|
||||
occurrences = [];
|
||||
}
|
||||
} else {
|
||||
// Recurring: get all occurrences in the window
|
||||
occurrences = rule.between(now, windowEnd, true);
|
||||
// Recurring: get the next `count` occurrences from now
|
||||
let searchFrom = now;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const next = rule.after(searchFrom, i === 0);
|
||||
if (!next) break;
|
||||
occurrences.push(next);
|
||||
searchFrom = new Date(next.getTime() + 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch maintenance info for notifications
|
||||
const maintenance = await db.getMaintenanceById(maintenance_id);
|
||||
const maintenanceTitle = maintenance?.title || "";
|
||||
const maintenanceDescription = maintenance?.description || null;
|
||||
|
||||
// Create events for each occurrence
|
||||
for (const occurrence of occurrences) {
|
||||
const eventStart = Math.floor(occurrence.getTime() / 1000);
|
||||
@@ -153,12 +215,13 @@ export const GenerateMaintenanceEvents = async (
|
||||
const alreadyExists = existing.some((e) => e.start_date_time === eventStart);
|
||||
|
||||
if (!alreadyExists) {
|
||||
const event = await db.createMaintenanceEvent({
|
||||
const event = await CreateMaintenanceEventWithNotification(
|
||||
maintenance_id,
|
||||
start_date_time: eventStart,
|
||||
end_date_time: eventEnd,
|
||||
status: determineEventStatus(eventStart, eventEnd),
|
||||
});
|
||||
eventStart,
|
||||
eventEnd,
|
||||
maintenanceTitle,
|
||||
maintenanceDescription,
|
||||
);
|
||||
createdEvents.push(event);
|
||||
}
|
||||
}
|
||||
@@ -202,8 +265,8 @@ export const CreateMaintenance = async (data: CreateMaintenanceInput): Promise<{
|
||||
await db.addMonitorsToMaintenanceWithStatus(maintenance.id, data.monitors);
|
||||
}
|
||||
|
||||
// Generate initial events for the next 7 days
|
||||
await GenerateMaintenanceEvents(maintenance.id, data.start_date_time, data.rrule, data.duration_seconds, 7);
|
||||
// Generate initial events
|
||||
await GenerateMaintenanceEvents(maintenance.id, data.start_date_time, data.rrule, data.duration_seconds, 1);
|
||||
|
||||
return {
|
||||
maintenance_id: maintenance.id,
|
||||
@@ -336,7 +399,7 @@ export const UpdateMaintenance = async (id: number, data: UpdateMaintenanceInput
|
||||
}
|
||||
}
|
||||
// Regenerate the event
|
||||
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 7);
|
||||
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 1);
|
||||
} else {
|
||||
// For recurring maintenances: delete future SCHEDULED events and regenerate
|
||||
for (const event of events) {
|
||||
@@ -345,8 +408,8 @@ export const UpdateMaintenance = async (id: number, data: UpdateMaintenanceInput
|
||||
await db.deleteMaintenanceEvent(event.id);
|
||||
}
|
||||
}
|
||||
// Regenerate events for the next 7 days
|
||||
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 7);
|
||||
// Regenerate events
|
||||
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,11 +440,16 @@ export const CreateMaintenanceEvent = async (data: CreateMaintenanceEventInput):
|
||||
throw new Error("End date/time must be after start date/time");
|
||||
}
|
||||
|
||||
const siteData = await GetAllSiteData();
|
||||
const notificationSettings =
|
||||
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
|
||||
const reminderBufferSeconds = notificationSettings.reminder_buffer_hours * 3600;
|
||||
|
||||
const event = await db.createMaintenanceEvent({
|
||||
maintenance_id: data.maintenance_id,
|
||||
start_date_time: data.start_date_time,
|
||||
end_date_time: data.end_date_time,
|
||||
status: determineEventStatus(data.start_date_time, data.end_date_time),
|
||||
status: determineEventStatus(data.start_date_time, data.end_date_time, reminderBufferSeconds),
|
||||
});
|
||||
|
||||
return event;
|
||||
@@ -534,20 +602,23 @@ export const formatDurationSeconds = (seconds: number): string => {
|
||||
|
||||
/**
|
||||
* Update maintenance event statuses based on current time:
|
||||
* 1. SCHEDULED events starting within 60 minutes → READY
|
||||
* 1. SCHEDULED events starting within the reminder buffer → READY
|
||||
* 2. READY events where current time is within start/end → ONGOING
|
||||
* 3. ONGOING events where end_date_time has passed → COMPLETED
|
||||
*/
|
||||
export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
|
||||
const currentTimestamp = GetMinuteStartNowTimestampUTC();
|
||||
const sixtyMinutesInSeconds = 60 * 60;
|
||||
const siteData = await GetAllSiteData();
|
||||
const siteVars = siteDataToVariables(siteData);
|
||||
const siteUrl = siteVars.site_url;
|
||||
//get global maintenance notification settings
|
||||
const notificationSettings =
|
||||
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
|
||||
|
||||
const reminderBufferSeconds = notificationSettings.reminder_buffer_hours * 3600;
|
||||
try {
|
||||
// 1. Mark SCHEDULED events starting within 60 minutes as READY
|
||||
const scheduledEvents = await db.getScheduledEventsStartingSoon(currentTimestamp, sixtyMinutesInSeconds);
|
||||
// 1. Mark SCHEDULED events starting within the reminder buffer as READY
|
||||
const scheduledEvents = await db.getScheduledEventsStartingSoon(currentTimestamp, reminderBufferSeconds);
|
||||
for (const event of scheduledEvents) {
|
||||
await db.updateMaintenanceEventStatus(event.id, GC.READY);
|
||||
console.log(`Maintenance event ${event.id} marked as READY (starts at ${event.start_date_time})`);
|
||||
@@ -557,15 +628,17 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
|
||||
new Date(event.start_date_time * 1000),
|
||||
new Date(currentTimestamp * 1000),
|
||||
);
|
||||
const update = maintenanceToVariables(
|
||||
event,
|
||||
monitorNames,
|
||||
`**is starting in ${timeUntilStart}**`,
|
||||
"starting_soon",
|
||||
"Maintenance Starting Soon",
|
||||
siteUrl,
|
||||
);
|
||||
await subscriberQueue.push(update);
|
||||
if (notificationSettings.event_types.reminder) {
|
||||
const update = maintenanceToVariables(
|
||||
event,
|
||||
monitorNames,
|
||||
`**is starting in ${timeUntilStart}**`,
|
||||
"starting_soon",
|
||||
"Maintenance Starting Soon",
|
||||
siteUrl,
|
||||
);
|
||||
await subscriberQueue.push(update);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Catch-up: SCHEDULED events that missed the READY window and already started → ONGOING
|
||||
@@ -575,15 +648,18 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
|
||||
console.log(`Maintenance event ${event.id} marked as ONGOING (catch-up from SCHEDULED)`);
|
||||
const monitors = await db.getMonitorsByMaintenanceId(event.maintenance_id);
|
||||
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
|
||||
const update = maintenanceToVariables(
|
||||
event,
|
||||
monitorNames,
|
||||
"**is now in progress**",
|
||||
"ongoing",
|
||||
"Maintenance In Progress",
|
||||
siteUrl,
|
||||
);
|
||||
await subscriberQueue.push(update);
|
||||
|
||||
if (notificationSettings.event_types.started) {
|
||||
const update = maintenanceToVariables(
|
||||
event,
|
||||
monitorNames,
|
||||
"**is now in progress**",
|
||||
"ongoing",
|
||||
"Maintenance In Progress",
|
||||
siteUrl,
|
||||
);
|
||||
await subscriberQueue.push(update);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Mark READY events that are now in progress as ONGOING
|
||||
@@ -593,15 +669,18 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
|
||||
console.log(`Maintenance event ${event.id} marked as ONGOING`);
|
||||
const monitors = await db.getMonitorsByMaintenanceId(event.maintenance_id);
|
||||
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
|
||||
const update = maintenanceToVariables(
|
||||
event,
|
||||
monitorNames,
|
||||
"**is now in progress**",
|
||||
"ongoing",
|
||||
"Maintenance In Progress",
|
||||
siteUrl,
|
||||
);
|
||||
await subscriberQueue.push(update);
|
||||
|
||||
if (notificationSettings.event_types.started) {
|
||||
const update = maintenanceToVariables(
|
||||
event,
|
||||
monitorNames,
|
||||
"**is now in progress**",
|
||||
"ongoing",
|
||||
"Maintenance In Progress",
|
||||
siteUrl,
|
||||
);
|
||||
await subscriberQueue.push(update);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Mark ONGOING events that have ended as COMPLETED
|
||||
@@ -611,15 +690,18 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
|
||||
console.log(`Maintenance event ${event.id} marked as COMPLETED`);
|
||||
const monitors = await db.getMonitorsByMaintenanceId(event.maintenance_id);
|
||||
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
|
||||
const update = maintenanceToVariables(
|
||||
event,
|
||||
monitorNames,
|
||||
"**has been completed**",
|
||||
"completed",
|
||||
"Maintenance Completed",
|
||||
siteUrl,
|
||||
);
|
||||
await subscriberQueue.push(update);
|
||||
|
||||
if (notificationSettings.event_types.ended) {
|
||||
const update = maintenanceToVariables(
|
||||
event,
|
||||
monitorNames,
|
||||
"**has been completed**",
|
||||
"completed",
|
||||
"Maintenance Completed",
|
||||
siteUrl,
|
||||
);
|
||||
await subscriberQueue.push(update);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating maintenance event statuses:", error);
|
||||
|
||||
@@ -127,19 +127,20 @@ export async function CreateMonitorAlertConfig(
|
||||
// Validate input
|
||||
validateMonitorAlertConfigInput(data);
|
||||
|
||||
if (!data.monitor_tag) {
|
||||
throw new Error("monitor_tag is required");
|
||||
if (!data.monitor_tags || data.monitor_tags.length === 0) {
|
||||
throw new Error("At least one monitor is required");
|
||||
}
|
||||
|
||||
// Check if monitor exists
|
||||
const monitor = await db.getMonitorByTag(data.monitor_tag);
|
||||
if (!monitor) {
|
||||
throw new Error(`Monitor with tag '${data.monitor_tag}' not found`);
|
||||
// Check if all monitors exist
|
||||
for (const tag of data.monitor_tags) {
|
||||
const monitor = await db.getMonitorByTag(tag);
|
||||
if (!monitor) {
|
||||
throw new Error(`Monitor with tag '${tag}' not found`);
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare insert data
|
||||
const insertData: MonitorAlertConfigInsert = {
|
||||
monitor_tag: data.monitor_tag,
|
||||
alert_for: data.alert_for,
|
||||
alert_value: data.alert_value,
|
||||
failure_threshold: data.failure_threshold,
|
||||
@@ -153,6 +154,9 @@ export async function CreateMonitorAlertConfig(
|
||||
// Insert alert config
|
||||
const id = await db.insertMonitorAlertConfig(insertData);
|
||||
|
||||
// Add monitors to junction table
|
||||
await db.addMonitorsToAlertConfig(id, data.monitor_tags);
|
||||
|
||||
// Add triggers if provided
|
||||
if (data.trigger_ids && data.trigger_ids.length > 0) {
|
||||
await db.addTriggersToMonitorAlertConfig(id, data.trigger_ids);
|
||||
@@ -194,6 +198,19 @@ export async function UpdateMonitorAlertConfig(
|
||||
validateAlertValue(alertFor, data.alert_value);
|
||||
}
|
||||
|
||||
// Validate monitor_tags if provided
|
||||
if (data.monitor_tags !== undefined) {
|
||||
if (data.monitor_tags.length === 0) {
|
||||
throw new Error("At least one monitor is required");
|
||||
}
|
||||
for (const tag of data.monitor_tags) {
|
||||
const monitor = await db.getMonitorByTag(tag);
|
||||
if (!monitor) {
|
||||
throw new Error(`Monitor with tag '${tag}' not found`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: MonitorAlertConfigUpdate = {};
|
||||
if (data.alert_for !== undefined) updateData.alert_for = data.alert_for;
|
||||
@@ -210,6 +227,11 @@ export async function UpdateMonitorAlertConfig(
|
||||
await db.updateMonitorAlertConfig(data.id, updateData);
|
||||
}
|
||||
|
||||
// Update monitors if provided
|
||||
if (data.monitor_tags !== undefined) {
|
||||
await db.replaceAlertConfigMonitors(data.id, data.monitor_tags);
|
||||
}
|
||||
|
||||
// Update triggers if provided
|
||||
if (data.trigger_ids !== undefined) {
|
||||
await db.replaceMonitorAlertConfigTriggers(data.id, data.trigger_ids);
|
||||
@@ -420,6 +442,7 @@ function validateAlertStatus(value: string): asserts value is MonitorAlertStatus
|
||||
*/
|
||||
export async function CreateMonitorAlertV2(
|
||||
configId: number,
|
||||
monitorTag?: string | null,
|
||||
incidentId?: number | null,
|
||||
): Promise<MonitorAlertV2Record> {
|
||||
// Check if config exists
|
||||
@@ -438,6 +461,7 @@ export async function CreateMonitorAlertV2(
|
||||
|
||||
const insertData: MonitorAlertV2Insert = {
|
||||
config_id: configId,
|
||||
monitor_tag: monitorTag || null,
|
||||
incident_id: incidentId || null,
|
||||
alert_status: "TRIGGERED",
|
||||
};
|
||||
|
||||
@@ -399,9 +399,7 @@ async function removeTagFromGroupMonitors(tag: string): Promise<void> {
|
||||
const weight = Math.round((1 / remaining.length) * 1000) / 1000;
|
||||
for (let i = 0; i < remaining.length; i++) {
|
||||
remaining[i].weight =
|
||||
i === remaining.length - 1
|
||||
? Math.round((1 - weight * (remaining.length - 1)) * 1000) / 1000
|
||||
: weight;
|
||||
i === remaining.length - 1 ? Math.round((1 - weight * (remaining.length - 1)) * 1000) / 1000 : weight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,13 +571,10 @@ export const GetBadge = async (badgeType: BadgeType, params: BadgeParams): Promi
|
||||
}
|
||||
}
|
||||
const defaultLocale = i18nConfig?.defaultLocale || "en";
|
||||
const activatedCodes = new Set(
|
||||
i18nConfig?.locales?.filter((l) => l.selected).map((l) => l.code) ?? ["en"],
|
||||
);
|
||||
const activatedCodes = new Set(i18nConfig?.locales?.filter((l) => l.selected).map((l) => l.code) ?? ["en"]);
|
||||
const requestedLocale = params.locale || defaultLocale;
|
||||
const locale = activatedCodes.has(requestedLocale) && isLocaleAvailable(requestedLocale)
|
||||
? requestedLocale
|
||||
: defaultLocale;
|
||||
const locale =
|
||||
activatedCodes.has(requestedLocale) && isLocaleAvailable(requestedLocale) ? requestedLocale : defaultLocale;
|
||||
|
||||
const statusLocaleKey: Record<string, string> = {
|
||||
[GC.UP]: "Operational",
|
||||
|
||||
@@ -19,6 +19,8 @@ import type {
|
||||
SiteSubMenuOptions,
|
||||
SiteDateTimeFormat,
|
||||
SiteSubscriptionsSettings,
|
||||
SitemapXMLConfig,
|
||||
GlobalMaintenanceNotificationSettings,
|
||||
} from "../../types/site.js";
|
||||
|
||||
export interface SiteDataTransformed {
|
||||
@@ -62,6 +64,10 @@ export interface SiteDataTransformed {
|
||||
globalPageVisibilitySettings?: GlobalPageVisibilitySettings;
|
||||
pageOrderingSettings?: PageOrderingSettings;
|
||||
dateAndTimeFormat?: SiteDateTimeFormat;
|
||||
metaSiteTitle?: string;
|
||||
metaSiteDescription?: string;
|
||||
sitemap?: SitemapXMLConfig;
|
||||
globalMaintenanceNotificationSettings?: GlobalMaintenanceNotificationSettings;
|
||||
}
|
||||
|
||||
export function InsertKeyValue(key: string, value: string): Promise<number[]> {
|
||||
|
||||
@@ -43,12 +43,12 @@ export const siteDataKeys: SiteDataKey[] = [
|
||||
},
|
||||
{
|
||||
key: "favicon",
|
||||
isValid: (value) => typeof value === "string" && value.trim().length > 0,
|
||||
isValid: (value) => typeof value === "string",
|
||||
data_type: "string",
|
||||
},
|
||||
{
|
||||
key: "logo",
|
||||
isValid: (value) => typeof value === "string" && value.trim().length > 0,
|
||||
isValid: (value) => typeof value === "string",
|
||||
data_type: "string",
|
||||
},
|
||||
{
|
||||
@@ -276,4 +276,24 @@ export const siteDataKeys: SiteDataKey[] = [
|
||||
isValid: IsValidJSONString,
|
||||
data_type: "object",
|
||||
},
|
||||
{
|
||||
key: "metaSiteTitle",
|
||||
isValid: (value) => typeof value === "string",
|
||||
data_type: "string",
|
||||
},
|
||||
{
|
||||
key: "metaSiteDescription",
|
||||
isValid: (value) => typeof value === "string",
|
||||
data_type: "string",
|
||||
},
|
||||
{
|
||||
key: "sitemap",
|
||||
isValid: IsValidJSONString,
|
||||
data_type: "object",
|
||||
},
|
||||
{
|
||||
key: "globalMaintenanceNotificationSettings",
|
||||
isValid: IsValidJSONString,
|
||||
data_type: "object",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -292,6 +292,12 @@ class DbImpl {
|
||||
isTriggerUsedInMonitorAlertConfig!: MonitorAlertConfigRepository["isTriggerUsedInMonitorAlertConfig"];
|
||||
getMonitorAlertConfigsByTriggerId!: MonitorAlertConfigRepository["getMonitorAlertConfigsByTriggerId"];
|
||||
|
||||
// ============ Monitor Alert Config Monitors ============
|
||||
addMonitorsToAlertConfig!: MonitorAlertConfigRepository["addMonitorsToAlertConfig"];
|
||||
removeAllMonitorsFromAlertConfig!: MonitorAlertConfigRepository["removeAllMonitorsFromAlertConfig"];
|
||||
replaceAlertConfigMonitors!: MonitorAlertConfigRepository["replaceAlertConfigMonitors"];
|
||||
getAlertConfigMonitorTags!: MonitorAlertConfigRepository["getAlertConfigMonitorTags"];
|
||||
|
||||
// ============ Monitor Alerts V2 ============
|
||||
insertMonitorAlertV2!: MonitorAlertConfigRepository["insertMonitorAlertV2"];
|
||||
updateMonitorAlertV2!: MonitorAlertConfigRepository["updateMonitorAlertV2"];
|
||||
@@ -692,6 +698,14 @@ class DbImpl {
|
||||
this.monitorAlertConfig,
|
||||
);
|
||||
|
||||
// Monitor Alert Config Monitors
|
||||
this.addMonitorsToAlertConfig = this.monitorAlertConfig.addMonitorsToAlertConfig.bind(this.monitorAlertConfig);
|
||||
this.removeAllMonitorsFromAlertConfig = this.monitorAlertConfig.removeAllMonitorsFromAlertConfig.bind(
|
||||
this.monitorAlertConfig,
|
||||
);
|
||||
this.replaceAlertConfigMonitors = this.monitorAlertConfig.replaceAlertConfigMonitors.bind(this.monitorAlertConfig);
|
||||
this.getAlertConfigMonitorTags = this.monitorAlertConfig.getAlertConfigMonitorTags.bind(this.monitorAlertConfig);
|
||||
|
||||
// Monitor Alerts V2
|
||||
this.insertMonitorAlertV2 = this.monitorAlertConfig.insertMonitorAlertV2.bind(this.monitorAlertConfig);
|
||||
this.updateMonitorAlertV2 = this.monitorAlertConfig.updateMonitorAlertV2.bind(this.monitorAlertConfig);
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
MonitorAlertConfigTriggerRecord,
|
||||
MonitorAlertConfigTriggerInsert,
|
||||
MonitorAlertConfigWithTriggers,
|
||||
MonitorAlertConfigMonitorRecord,
|
||||
MonitorAlertConfigMonitorInsert,
|
||||
TriggerRecord,
|
||||
MonitorAlertV2Record,
|
||||
MonitorAlertV2Insert,
|
||||
@@ -29,7 +31,7 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
async insertMonitorAlertConfig(data: MonitorAlertConfigInsert): Promise<number> {
|
||||
const dbType = GetDbType();
|
||||
const insertData = {
|
||||
monitor_tag: data.monitor_tag,
|
||||
monitor_tag: data.monitor_tag || null,
|
||||
alert_for: data.alert_for,
|
||||
alert_value: data.alert_value,
|
||||
failure_threshold: data.failure_threshold,
|
||||
@@ -88,29 +90,35 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
* Get monitor alert configs with optional filtering
|
||||
*/
|
||||
async getMonitorAlertConfigs(filter: MonitorAlertConfigFilter): Promise<MonitorAlertConfigRecord[]> {
|
||||
let query = this.knex("monitor_alerts_config").whereRaw("1=1");
|
||||
let query = this.knex("monitor_alerts_config as mac").select("mac.*").whereRaw("1=1");
|
||||
|
||||
if (filter.id !== undefined) {
|
||||
query = query.andWhere("id", filter.id);
|
||||
query = query.andWhere("mac.id", filter.id);
|
||||
}
|
||||
if (filter.monitor_tag !== undefined) {
|
||||
query = query.andWhere("monitor_tag", filter.monitor_tag);
|
||||
query = query
|
||||
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
|
||||
.andWhere("macm.monitor_tag", filter.monitor_tag);
|
||||
}
|
||||
if (filter.alert_for !== undefined) {
|
||||
query = query.andWhere("alert_for", filter.alert_for);
|
||||
query = query.andWhere("mac.alert_for", filter.alert_for);
|
||||
}
|
||||
if (filter.is_active !== undefined) {
|
||||
query = query.andWhere("is_active", filter.is_active);
|
||||
query = query.andWhere("mac.is_active", filter.is_active);
|
||||
}
|
||||
|
||||
return await query.orderBy("id", "desc");
|
||||
return await query.orderBy("mac.id", "desc");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all monitor alert configs for a specific monitor tag
|
||||
*/
|
||||
async getMonitorAlertConfigsByMonitorTag(monitorTag: string): Promise<MonitorAlertConfigRecord[]> {
|
||||
return await this.knex("monitor_alerts_config").where({ monitor_tag: monitorTag }).orderBy("id", "desc");
|
||||
return await this.knex("monitor_alerts_config as mac")
|
||||
.select("mac.*")
|
||||
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
|
||||
.where("macm.monitor_tag", monitorTag)
|
||||
.orderBy("mac.id", "desc");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,9 +132,12 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
* Get all active monitor alert configs for a specific monitor
|
||||
*/
|
||||
async getActiveMonitorAlertConfigsByMonitorTag(monitorTag: string): Promise<MonitorAlertConfigRecord[]> {
|
||||
return await this.knex("monitor_alerts_config")
|
||||
.where({ monitor_tag: monitorTag, is_active: "YES" })
|
||||
.orderBy("id", "desc");
|
||||
return await this.knex("monitor_alerts_config as mac")
|
||||
.select("mac.*")
|
||||
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
|
||||
.where("macm.monitor_tag", monitorTag)
|
||||
.andWhere("mac.is_active", "YES")
|
||||
.orderBy("mac.id", "desc");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,26 +151,51 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
* Delete all monitor alert configs for a specific monitor tag
|
||||
*/
|
||||
async deleteMonitorAlertConfigsByMonitorTag(monitorTag: string): Promise<number> {
|
||||
return await this.knex("monitor_alerts_config").where({ monitor_tag: monitorTag }).del();
|
||||
// Find all config IDs that have this monitor tag in the junction table
|
||||
const configIds = await this.knex("monitor_alerts_config_monitors")
|
||||
.select("monitor_alerts_id")
|
||||
.where({ monitor_tag: monitorTag });
|
||||
|
||||
if (configIds.length === 0) return 0;
|
||||
|
||||
// Remove the monitor from the junction table
|
||||
await this.knex("monitor_alerts_config_monitors").where({ monitor_tag: monitorTag }).del();
|
||||
|
||||
// Delete any configs that now have zero monitors
|
||||
const ids = configIds.map((r: { monitor_alerts_id: number }) => r.monitor_alerts_id);
|
||||
let deletedCount = 0;
|
||||
for (const id of ids) {
|
||||
const remainingMonitors = await this.knex("monitor_alerts_config_monitors")
|
||||
.count("* as count")
|
||||
.where({ monitor_alerts_id: id })
|
||||
.first<CountResult>();
|
||||
if (Number(remainingMonitors?.count) === 0) {
|
||||
await this.knex("monitor_alerts_config").where({ id }).del();
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count monitor alert configs with optional filtering
|
||||
*/
|
||||
async getMonitorAlertConfigsCount(filter: MonitorAlertConfigFilter): Promise<CountResult | undefined> {
|
||||
let query = this.knex("monitor_alerts_config").count("* as count");
|
||||
let query = this.knex("monitor_alerts_config as mac").count("* as count");
|
||||
|
||||
if (filter.id !== undefined) {
|
||||
query = query.andWhere("id", filter.id);
|
||||
query = query.andWhere("mac.id", filter.id);
|
||||
}
|
||||
if (filter.monitor_tag !== undefined) {
|
||||
query = query.andWhere("monitor_tag", filter.monitor_tag);
|
||||
query = query
|
||||
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
|
||||
.andWhere("macm.monitor_tag", filter.monitor_tag);
|
||||
}
|
||||
if (filter.alert_for !== undefined) {
|
||||
query = query.andWhere("alert_for", filter.alert_for);
|
||||
query = query.andWhere("mac.alert_for", filter.alert_for);
|
||||
}
|
||||
if (filter.is_active !== undefined) {
|
||||
query = query.andWhere("is_active", filter.is_active);
|
||||
query = query.andWhere("mac.is_active", filter.is_active);
|
||||
}
|
||||
|
||||
return await query.first<CountResult>();
|
||||
@@ -174,29 +210,33 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
filter?: MonitorAlertConfigFilter,
|
||||
): Promise<{ configs: MonitorAlertConfigRecord[]; total: number }> {
|
||||
// Build count query
|
||||
let countQuery = this.knex("monitor_alerts_config").count("* as count");
|
||||
let countQuery = this.knex("monitor_alerts_config as mac").count("* as count");
|
||||
if (filter?.monitor_tag) {
|
||||
countQuery = countQuery.where("monitor_tag", filter.monitor_tag);
|
||||
countQuery = countQuery
|
||||
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
|
||||
.where("macm.monitor_tag", filter.monitor_tag);
|
||||
}
|
||||
if (filter?.is_active) {
|
||||
countQuery = countQuery.andWhere("is_active", filter.is_active);
|
||||
countQuery = countQuery.andWhere("mac.is_active", filter.is_active);
|
||||
}
|
||||
if (filter?.alert_for) {
|
||||
countQuery = countQuery.andWhere("alert_for", filter.alert_for);
|
||||
countQuery = countQuery.andWhere("mac.alert_for", filter.alert_for);
|
||||
}
|
||||
const totalResult = await countQuery.first<CountResult>();
|
||||
const total = totalResult ? Number(totalResult.count) : 0;
|
||||
|
||||
// Build paginated query
|
||||
let query = this.knex("monitor_alerts_config").orderBy("id", "desc");
|
||||
let query = this.knex("monitor_alerts_config as mac").select("mac.*").orderBy("mac.id", "desc");
|
||||
if (filter?.monitor_tag) {
|
||||
query = query.where("monitor_tag", filter.monitor_tag);
|
||||
query = query
|
||||
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
|
||||
.where("macm.monitor_tag", filter.monitor_tag);
|
||||
}
|
||||
if (filter?.is_active) {
|
||||
query = query.andWhere("is_active", filter.is_active);
|
||||
query = query.andWhere("mac.is_active", filter.is_active);
|
||||
}
|
||||
if (filter?.alert_for) {
|
||||
query = query.andWhere("alert_for", filter.alert_for);
|
||||
query = query.andWhere("mac.alert_for", filter.alert_for);
|
||||
}
|
||||
const configs = await query.limit(limit).offset((page - 1) * limit);
|
||||
|
||||
@@ -278,6 +318,51 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Monitor Alert Config Monitors CRUD ============
|
||||
|
||||
/**
|
||||
* Add multiple monitors to an alert config
|
||||
*/
|
||||
async addMonitorsToAlertConfig(alertConfigId: number, monitorTags: string[]): Promise<void> {
|
||||
if (monitorTags.length === 0) return;
|
||||
|
||||
const inserts = monitorTags.map((monitorTag) => ({
|
||||
monitor_alerts_id: alertConfigId,
|
||||
monitor_tag: monitorTag,
|
||||
created_at: this.knex.fn.now(),
|
||||
updated_at: this.knex.fn.now(),
|
||||
}));
|
||||
|
||||
await this.knex("monitor_alerts_config_monitors").insert(inserts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all monitors from an alert config
|
||||
*/
|
||||
async removeAllMonitorsFromAlertConfig(alertConfigId: number): Promise<number> {
|
||||
return await this.knex("monitor_alerts_config_monitors").where({ monitor_alerts_id: alertConfigId }).del();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all monitors for an alert config (remove old, add new)
|
||||
*/
|
||||
async replaceAlertConfigMonitors(alertConfigId: number, monitorTags: string[]): Promise<void> {
|
||||
await this.removeAllMonitorsFromAlertConfig(alertConfigId);
|
||||
if (monitorTags.length > 0) {
|
||||
await this.addMonitorsToAlertConfig(alertConfigId, monitorTags);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get monitor tags for an alert config
|
||||
*/
|
||||
async getAlertConfigMonitorTags(alertConfigId: number): Promise<string[]> {
|
||||
const records = await this.knex("monitor_alerts_config_monitors")
|
||||
.select("monitor_tag")
|
||||
.where({ monitor_alerts_id: alertConfigId });
|
||||
return records.map((r: { monitor_tag: string }) => r.monitor_tag);
|
||||
}
|
||||
|
||||
// ============ Composite / Join Operations ============
|
||||
|
||||
/**
|
||||
@@ -292,9 +377,12 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
.select("t.*")
|
||||
.where("mact.monitor_alerts_id", id);
|
||||
|
||||
const monitorTags = await this.getAlertConfigMonitorTags(id);
|
||||
|
||||
return {
|
||||
...config,
|
||||
triggers: triggerRecords as TriggerRecord[],
|
||||
monitor_tags: monitorTags,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -311,9 +399,12 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
.select("t.*")
|
||||
.where("mact.monitor_alerts_id", config.id);
|
||||
|
||||
const monitorTags = await this.getAlertConfigMonitorTags(config.id);
|
||||
|
||||
result.push({
|
||||
...config,
|
||||
triggers: triggerRecords as TriggerRecord[],
|
||||
monitor_tags: monitorTags,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -333,9 +424,12 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
.select("t.*")
|
||||
.where("mact.monitor_alerts_id", config.id);
|
||||
|
||||
const monitorTags = await this.getAlertConfigMonitorTags(config.id);
|
||||
|
||||
result.push({
|
||||
...config,
|
||||
triggers: triggerRecords as TriggerRecord[],
|
||||
monitor_tags: monitorTags,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -373,6 +467,7 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
const dbType = GetDbType();
|
||||
const insertData: Record<string, unknown> = {
|
||||
config_id: data.config_id,
|
||||
monitor_tag: data.monitor_tag || null,
|
||||
incident_id: data.incident_id || null,
|
||||
alert_status: data.alert_status,
|
||||
created_at: this.knex.fn.now(),
|
||||
@@ -432,6 +527,9 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
if (filter.config_id !== undefined) {
|
||||
query = query.andWhere("config_id", filter.config_id);
|
||||
}
|
||||
if (filter.monitor_tag !== undefined) {
|
||||
query = query.andWhere("monitor_tag", filter.monitor_tag);
|
||||
}
|
||||
if (filter.incident_id !== undefined) {
|
||||
query = query.andWhere("incident_id", filter.incident_id);
|
||||
}
|
||||
|
||||
@@ -164,6 +164,19 @@ const seedSiteData = {
|
||||
dateOnly: "PP",
|
||||
timeOnly: "p",
|
||||
},
|
||||
sitemap: {
|
||||
mode: "off",
|
||||
urls: [],
|
||||
},
|
||||
globalMaintenanceNotificationSettings: {
|
||||
event_types: {
|
||||
created: false,
|
||||
reminder: true,
|
||||
started: true,
|
||||
ended: true,
|
||||
},
|
||||
reminder_buffer_hours: 1,
|
||||
},
|
||||
};
|
||||
|
||||
export default seedSiteData;
|
||||
|
||||
@@ -10,9 +10,11 @@ export function alertToVariables(
|
||||
config: MonitorAlertConfigRecord,
|
||||
alert: MonitorAlertV2Record,
|
||||
siteVars: SiteDataForNotification,
|
||||
monitorTag?: string,
|
||||
): AlertVariableMap {
|
||||
const createdAtDate = alert.created_at instanceof Date ? alert.created_at : new Date(alert.created_at);
|
||||
const alert_name = `Alert ${config.monitor_tag} for ${config.alert_for} ${config.alert_value} ${alert.alert_status} at ${createdAtDate.toISOString()}`;
|
||||
const effectiveMonitorTag = monitorTag || config.monitor_tag || "unknown";
|
||||
const alert_name = `Alert ${effectiveMonitorTag} for ${config.alert_for} ${config.alert_value} ${alert.alert_status} at ${createdAtDate.toISOString()}`;
|
||||
|
||||
return {
|
||||
alert_id: alert.id,
|
||||
@@ -24,7 +26,7 @@ export function alertToVariables(
|
||||
alert_message: config.alert_description || "",
|
||||
alert_source: GC.ALERT,
|
||||
alert_timestamp: createdAtDate.toISOString(),
|
||||
alert_cta_url: siteVars.site_url + "monitors/" + config.monitor_tag,
|
||||
alert_cta_url: siteVars.site_url + "monitors/" + effectiveMonitorTag,
|
||||
alert_cta_text: "Open Alert Details",
|
||||
alert_incident_id: alert.incident_id ? alert.incident_id : undefined,
|
||||
alert_incident_url: alert.incident_id ? siteVars.site_url + "incidents/" + alert.incident_id : undefined,
|
||||
|
||||
@@ -141,8 +141,9 @@ async function sendAlertNotifications(
|
||||
activeAlert: MonitorAlertV2Record,
|
||||
monitor_alerts_configured: MonitorAlertConfigRecord,
|
||||
templateSiteVars: SiteDataForNotification,
|
||||
monitorTag?: string,
|
||||
): Promise<void> {
|
||||
const templateAlertVars = alertToVariables(monitor_alerts_configured, activeAlert, templateSiteVars);
|
||||
const templateAlertVars = alertToVariables(monitor_alerts_configured, activeAlert, templateSiteVars, monitorTag);
|
||||
const triggers = await GetTriggersByMonitorAlertConfigId(monitor_alerts_configured.id);
|
||||
|
||||
for (let i = 0; i < triggers.length; i++) {
|
||||
@@ -233,9 +234,10 @@ const addWorker = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get existing alerts
|
||||
// Get existing alerts for this specific monitor + config combination
|
||||
let alertsExisting = await GetMonitorAlertsV2({
|
||||
config_id: monitor_alerts_configured.id,
|
||||
monitor_tag: monitor_tag,
|
||||
alert_status: GC.TRIGGERED,
|
||||
});
|
||||
let activeAlert = null;
|
||||
@@ -244,9 +246,9 @@ const addWorker = () => {
|
||||
}
|
||||
|
||||
if (isAffected) {
|
||||
// Trigger alert if not already active
|
||||
// Trigger alert if not already active for this monitor
|
||||
if (!activeAlert) {
|
||||
activeAlert = await CreateMonitorAlertV2(monitor_alerts_configured.id);
|
||||
activeAlert = await CreateMonitorAlertV2(monitor_alerts_configured.id, monitor_tag);
|
||||
if (monitor_alerts_configured.create_incident === GC.YES) {
|
||||
let newIncidentNumber = await createNewIncident(
|
||||
activeAlert,
|
||||
@@ -261,7 +263,7 @@ const addWorker = () => {
|
||||
}
|
||||
}
|
||||
// Send triggered alert notifications
|
||||
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars);
|
||||
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars, monitor_tag);
|
||||
}
|
||||
} else {
|
||||
// Resolve any existing alert
|
||||
@@ -302,7 +304,7 @@ const addWorker = () => {
|
||||
}
|
||||
|
||||
// Send resolution notifications
|
||||
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars);
|
||||
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars, monitor_tag);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -348,6 +350,7 @@ export const push = async (monitor_tag: string, ts: number, status: string, opti
|
||||
monitor_tag: monitor.tag,
|
||||
is_active: GC.YES,
|
||||
});
|
||||
|
||||
if (monitorAlertsConfigurations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ const addWorker = () => {
|
||||
fromEmail,
|
||||
templateTextBody,
|
||||
);
|
||||
console.log(`📧 Email sent to ${toEmails}`);
|
||||
// console.log(`📧 Email sent to ${toEmails}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to send email to ${toEmails}:`, error);
|
||||
throw error; // Re-throw to trigger retry
|
||||
|
||||
@@ -42,6 +42,7 @@ const addWorker = () => {
|
||||
});
|
||||
|
||||
if (!dbRes) {
|
||||
console.error("Failed to insert monitoring data for monitorTag:", monitorTag, "timestamp:", ts);
|
||||
throw new Error("Failed to insert monitoring data");
|
||||
}
|
||||
|
||||
@@ -52,7 +53,6 @@ const addWorker = () => {
|
||||
latency: latency,
|
||||
type: type,
|
||||
});
|
||||
|
||||
alertingQueue.push(monitorTag, ts, status);
|
||||
|
||||
return dbRes;
|
||||
|
||||
@@ -4,7 +4,7 @@ import db from "../db/db.js";
|
||||
import { rrulestr } from "rrule";
|
||||
import { addDays } from "date-fns";
|
||||
import type { MaintenanceRecord, MaintenanceEventRecord } from "../types/db.js";
|
||||
import { determineEventStatus } from "../controllers/maintenanceController.js";
|
||||
import { determineEventStatus, CreateMaintenanceEventWithNotification } from "../controllers/maintenanceController.js";
|
||||
|
||||
let maintenanceSchedulerQueue: Queue | null = null;
|
||||
let worker: Worker | null = null;
|
||||
@@ -58,12 +58,13 @@ const generateEventsForMaintenance = async (maintenance: MaintenanceRecord): Pro
|
||||
|
||||
// Check if event already exists for this start time
|
||||
if (!existingStartTimes.has(eventStart)) {
|
||||
await db.createMaintenanceEvent({
|
||||
maintenance_id: maintenance.id,
|
||||
start_date_time: eventStart,
|
||||
end_date_time: eventEnd,
|
||||
status: determineEventStatus(eventStart, eventEnd),
|
||||
});
|
||||
await CreateMaintenanceEventWithNotification(
|
||||
maintenance.id,
|
||||
eventStart,
|
||||
eventEnd,
|
||||
maintenance.title,
|
||||
maintenance.description || null,
|
||||
);
|
||||
eventsCreated++;
|
||||
console.log(
|
||||
`Created maintenance event for "${maintenance.title}" at ${new Date(eventStart * 1000).toISOString()}`,
|
||||
|
||||
@@ -154,25 +154,32 @@ class ApiCall {
|
||||
}
|
||||
}
|
||||
|
||||
if (!!!evalResp) {
|
||||
if (!evalResp || typeof evalResp !== "object") {
|
||||
evalResp = {
|
||||
status: GC.DOWN,
|
||||
latency: latency,
|
||||
type: GC.ERROR,
|
||||
};
|
||||
} else if (
|
||||
!!!evalResp.status ||
|
||||
([GC.UP, GC.DOWN, GC.DEGRADED, GC.MAINTENANCE] as string[]).indexOf(evalResp.status) === -1
|
||||
) {
|
||||
errorMessage += " | Eval must return an object with 'status' and 'latency' fields, got no response";
|
||||
} else if (evalResp.status === undefined) {
|
||||
evalResp = {
|
||||
status: GC.DOWN,
|
||||
latency: latency,
|
||||
type: GC.ERROR,
|
||||
};
|
||||
errorMessage += ` | Eval must return an object with a 'status' field (one of: ${GC.UP}, ${GC.DOWN}, ${GC.DEGRADED}, ${GC.MAINTENANCE}), but 'status' was missing`;
|
||||
} else if (([GC.UP, GC.DOWN, GC.DEGRADED, GC.MAINTENANCE] as string[]).indexOf(evalResp.status) === -1) {
|
||||
evalResp = {
|
||||
status: GC.DOWN,
|
||||
latency: latency,
|
||||
type: GC.ERROR,
|
||||
};
|
||||
errorMessage += ` | Eval returned invalid 'status' value "${evalResp.status}". Must be one of: ${GC.UP}, ${GC.DOWN}, ${GC.DEGRADED}, ${GC.MAINTENANCE}`;
|
||||
} else {
|
||||
evalResp.type = GC.REALTIME;
|
||||
// Ensure latency is a valid number
|
||||
// Ensure latency is a valid number; fall back to measured latency
|
||||
if (typeof evalResp.latency !== "number" || isNaN(evalResp.latency)) {
|
||||
errorMessage += ` | Eval 'latency' must be a number, got ${JSON.stringify(evalResp.latency)}. Using measured latency instead`;
|
||||
evalResp.latency = latency;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ class TcpCall {
|
||||
error_message: `Error in tcpEval: ${message}`,
|
||||
};
|
||||
}
|
||||
if (!!!evalResp) {
|
||||
if (!evalResp) {
|
||||
const message = "tcpEval did not return a valid response.";
|
||||
console.log(`Error in tcpEval for ${tag}:`, message);
|
||||
return {
|
||||
@@ -65,6 +65,19 @@ class TcpCall {
|
||||
error_message: `Error in tcpEval: ${message}`,
|
||||
};
|
||||
}
|
||||
|
||||
//evalResp to be an object with status and latency
|
||||
if (!("status" in evalResp) || !("latency" in evalResp)) {
|
||||
const message = "tcpEval did not return status or latency.";
|
||||
console.log(`Error in tcpEval for ${tag}:`, message);
|
||||
return {
|
||||
status: GC.DOWN,
|
||||
latency: 0,
|
||||
type: GC.ERROR,
|
||||
error_message: `Error in tcpEval: ${message}`,
|
||||
};
|
||||
}
|
||||
|
||||
//reduce to get the status
|
||||
return {
|
||||
status: evalResp?.status || GC.DOWN,
|
||||
|
||||
@@ -444,6 +444,9 @@ export interface PageSettingsType {
|
||||
mobile: number;
|
||||
};
|
||||
monitor_layout_style: "default-list" | "default-grid" | "compact-list" | "compact-grid";
|
||||
metaPageTitle?: string;
|
||||
metaPageDescription?: string;
|
||||
socialPagePreviewImage?: string;
|
||||
}
|
||||
|
||||
export interface PageRecordTyped {
|
||||
@@ -625,7 +628,7 @@ export type YesNoType = "YES" | "NO";
|
||||
|
||||
export interface MonitorAlertConfigRecord {
|
||||
id: number;
|
||||
monitor_tag: string;
|
||||
monitor_tag: string | null;
|
||||
alert_for: AlertForType;
|
||||
alert_value: string;
|
||||
failure_threshold: number;
|
||||
@@ -639,7 +642,7 @@ export interface MonitorAlertConfigRecord {
|
||||
}
|
||||
|
||||
export interface MonitorAlertConfigInsert {
|
||||
monitor_tag: string;
|
||||
monitor_tag?: string | null;
|
||||
alert_for: AlertForType;
|
||||
alert_value: string;
|
||||
failure_threshold: number;
|
||||
@@ -681,13 +684,27 @@ export interface MonitorAlertConfigTriggerInsert {
|
||||
trigger_id: number;
|
||||
}
|
||||
|
||||
// ============ monitor_alerts_config_monitors table ============
|
||||
export interface MonitorAlertConfigMonitorRecord {
|
||||
monitor_alerts_id: number;
|
||||
monitor_tag: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface MonitorAlertConfigMonitorInsert {
|
||||
monitor_alerts_id: number;
|
||||
monitor_tag: string;
|
||||
}
|
||||
|
||||
// ============ Composite types for monitor_alerts_config ============
|
||||
export interface MonitorAlertConfigWithTriggers extends MonitorAlertConfigRecord {
|
||||
triggers: TriggerRecord[];
|
||||
monitor_tags: string[];
|
||||
}
|
||||
|
||||
export interface MonitorAlertConfigCreateInput {
|
||||
monitor_tag: string;
|
||||
monitor_tags: string[];
|
||||
alert_for: AlertForType;
|
||||
alert_value: string;
|
||||
failure_threshold: number;
|
||||
@@ -701,6 +718,7 @@ export interface MonitorAlertConfigCreateInput {
|
||||
|
||||
export interface MonitorAlertConfigUpdateInput {
|
||||
id: number;
|
||||
monitor_tags?: string[];
|
||||
alert_for?: AlertForType;
|
||||
alert_value?: string;
|
||||
failure_threshold?: number;
|
||||
@@ -718,6 +736,7 @@ export type MonitorAlertStatusType = "TRIGGERED" | "RESOLVED";
|
||||
export interface MonitorAlertV2Record {
|
||||
id: number;
|
||||
config_id: number;
|
||||
monitor_tag: string | null;
|
||||
incident_id: number | null;
|
||||
alert_status: MonitorAlertStatusType;
|
||||
created_at: Date;
|
||||
@@ -726,11 +745,13 @@ export interface MonitorAlertV2Record {
|
||||
|
||||
export interface MonitorAlertV2Insert {
|
||||
config_id: number;
|
||||
monitor_tag?: string | null;
|
||||
incident_id?: number | null;
|
||||
alert_status: MonitorAlertStatusType;
|
||||
}
|
||||
|
||||
export interface MonitorAlertV2Update {
|
||||
monitor_tag?: string | null;
|
||||
incident_id?: number | null;
|
||||
alert_status?: MonitorAlertStatusType;
|
||||
}
|
||||
@@ -738,6 +759,7 @@ export interface MonitorAlertV2Update {
|
||||
export interface MonitorAlertV2Filter {
|
||||
id?: number;
|
||||
config_id?: number;
|
||||
monitor_tag?: string;
|
||||
incident_id?: number;
|
||||
alert_status?: MonitorAlertStatusType;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,12 @@
|
||||
}
|
||||
gtag("js", new Date());
|
||||
|
||||
gtag("config", "{{id}}");
|
||||
var gtagConfig = {};
|
||||
var transportUrl = "{{transport_url}}";
|
||||
if (!!transportUrl) {
|
||||
gtagConfig.transport_url = transportUrl;
|
||||
}
|
||||
gtag("config", "{{id}}", gtagConfig);
|
||||
|
||||
window.addEventListener("analyticsEvent", function (e) {
|
||||
// Extract event name and data from the custom event
|
||||
@@ -43,6 +48,7 @@
|
||||
|
||||
//on dom ready
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
loadJS("https://www.googletagmanager.com/gtag/js?id={{id}}", initJS, document.getElementsByTagName("head")[0]);
|
||||
var scriptHost = "{{script_host}}" || "https://www.googletagmanager.com/gtag/js?id={{id}}";
|
||||
loadJS(scriptHost, initJS, document.getElementsByTagName("head")[0]);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -141,3 +141,20 @@ export interface SiteDateTimeFormat {
|
||||
dateOnly: string;
|
||||
timeOnly: string;
|
||||
}
|
||||
|
||||
export interface SitemapXMLConfig {
|
||||
mode: "auto" | "manual" | "off";
|
||||
urls: {
|
||||
loc: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface GlobalMaintenanceNotificationSettings {
|
||||
event_types: {
|
||||
created: boolean;
|
||||
reminder: boolean;
|
||||
started: boolean;
|
||||
ended: boolean;
|
||||
};
|
||||
reminder_buffer_hours: number;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<Toaster />
|
||||
|
||||
<svelte:head>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
{@html `<style>:root{--up:${colorUp};--degraded:${colorDegraded};--down:${colorDown};--maintenance:${colorMaintenance};}</style>`}
|
||||
</svelte:head>
|
||||
<main>
|
||||
|
||||
@@ -264,7 +264,7 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
updatedMaintenance.start_date_time,
|
||||
updatedMaintenance.rrule,
|
||||
updatedMaintenance.duration_seconds,
|
||||
7,
|
||||
1,
|
||||
);
|
||||
}
|
||||
} else if (!isOneTime && scheduleChanged) {
|
||||
@@ -287,7 +287,7 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
updatedMaintenance.start_date_time,
|
||||
updatedMaintenance.rrule,
|
||||
updatedMaintenance.duration_seconds,
|
||||
7,
|
||||
1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
<ModeWatcher />
|
||||
<Toaster />
|
||||
|
||||
<svelte:head></svelte:head>
|
||||
<main>
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
@@ -221,6 +221,10 @@
|
||||
{
|
||||
"title": "Internationalization",
|
||||
"content": "v4/internationalization"
|
||||
},
|
||||
{
|
||||
"title": "Analytics",
|
||||
"content": "v4/analytics"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -264,6 +268,10 @@
|
||||
{
|
||||
"title": "Custom JS & CSS",
|
||||
"content": "v4/guides/custom-js-css-guide"
|
||||
},
|
||||
{
|
||||
"title": "Kener vs Other Status Pages",
|
||||
"content": "v4/guides/comparison"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -295,6 +303,18 @@
|
||||
"group": "v4.x",
|
||||
"collapsible": false,
|
||||
"pages": [
|
||||
{
|
||||
"title": "v4.0.22",
|
||||
"content": "v4/changelogs/v4.0.22"
|
||||
},
|
||||
{
|
||||
"title": "v4.0.20",
|
||||
"content": "v4/changelogs/v4.0.20"
|
||||
},
|
||||
{
|
||||
"title": "v4.0.19",
|
||||
"content": "v4/changelogs/v4.0.19"
|
||||
},
|
||||
{
|
||||
"title": "v4.0.18",
|
||||
"content": "v4/changelogs/v4.0.18"
|
||||
|
||||
@@ -23,8 +23,6 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.config.name}</title>
|
||||
<meta name="description" content="Documentation for {data.config.name}" />
|
||||
<!-- favicon -->
|
||||
<link rel="icon" href={data.config.favicon} />
|
||||
</svelte:head>
|
||||
|
||||
@@ -149,9 +149,45 @@
|
||||
<svelte:head>
|
||||
<title>{data.title} - Documentation</title>
|
||||
<meta name="description" content={data.description || `Documentation for ${data.title}`} />
|
||||
<meta property="article:author" content="https://github.com/rajnandan1" />
|
||||
<link rel="canonical" href={`https://kener.ing/docs/${data.slug}`} />
|
||||
<meta property="og:title" content="{data.title} - Documentation" />
|
||||
<meta property="og:description" content={data.description || `Documentation for ${data.title}`} />
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="og:url" content={`https://kener.ing/docs/${data.slug}`} />
|
||||
<meta property="og:logo" content="https://kener.ing/logo96.png" />
|
||||
<meta property="og:image" content="https://kener.ing/og.jpg" />
|
||||
<meta name="twitter:title" content="{data.title} - Documentation" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:description" content={data.description || `Documentation for ${data.title}`} />
|
||||
<meta name="twitter:image" content="https://kener.ing/og.jpg" />
|
||||
{@html `<script type="application/ld+json">${JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 1,
|
||||
name: "Documentation",
|
||||
item: "https://kener.ing/docs"
|
||||
},
|
||||
...(data.group
|
||||
? [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: data.group
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: data.group ? 3 : 2,
|
||||
name: data.title,
|
||||
item: `https://kener.ing/docs/${data.slug}`
|
||||
}
|
||||
]
|
||||
})}</script>`}
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto flex justify-between gap-8">
|
||||
|
||||
@@ -9,23 +9,8 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Kener Documentation</title>
|
||||
<!-- social preview og.jpg -->
|
||||
<meta property="og:image" content="/og.jpg" />
|
||||
<meta property="og:title" content="Kener Documentation" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Comprehensive documentation for Kener, the open-source status page generator. Learn how to set up, customize, and manage your own status page with Kener."
|
||||
/>
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Kener Documentation" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Comprehensive documentation for Kener, the open-source status page generator. Learn how to set up, customize, and manage your own status page with Kener."
|
||||
/>
|
||||
<meta name="twitter:image" content="/og.jpg" />
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-Q3MLRXCBFT"></script>
|
||||
<script async src="https://saki-production.up.railway.app/googletagmanager/gtag/js?id=G-Q3MLRXCBFT"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
@@ -33,7 +18,7 @@
|
||||
}
|
||||
gtag("js", new Date());
|
||||
|
||||
gtag("config", "G-Q3MLRXCBFT");
|
||||
gtag("config", "G-Q3MLRXCBFT", { transport_url: "https://saki-production.up.railway.app/google-analytics" });
|
||||
</script>
|
||||
</svelte:head>
|
||||
|
||||
|
||||
+770
-283
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
---
|
||||
title: Analytics
|
||||
description: Connect analytics providers to track traffic on your public status page
|
||||
---
|
||||
|
||||
Kener supports injecting analytics scripts into the public status page. Go to **Manage → Analytics Providers** to configure any of the supported providers.
|
||||
|
||||
## How it works {#how-it-works}
|
||||
|
||||
When a provider is enabled, Kener injects its tracking script into every public status page response via `/capture.js`. Multiple providers can be active simultaneously.
|
||||
|
||||
## Supported providers {#supported-providers}
|
||||
|
||||
| Provider | Key |
|
||||
| ----------------- | ---------------------------- |
|
||||
| Google Analytics | `analytics.googleTagManager` |
|
||||
| Plausible | `analytics.plausible` |
|
||||
| Mixpanel | `analytics.mixpanel` |
|
||||
| Amplitude | `analytics.amplitude` |
|
||||
| Microsoft Clarity | `analytics.clarity` |
|
||||
| Umami | `analytics.umami` |
|
||||
| PostHog | `analytics.posthog` |
|
||||
|
||||
---
|
||||
|
||||
## Google Analytics {#google-analytics}
|
||||
|
||||
Uses Google Tag Manager / gtag.js.
|
||||
|
||||
| Field | Required | Example |
|
||||
| -------------- | -------- | ----------------------------------------------------------- |
|
||||
| Measurement ID | Yes | `G-S05E5E5E5E5` |
|
||||
| Transport URL | No | `https://www.google-analytics.com` |
|
||||
| Script Host | No | `https://www.googletagmanager.com/gtag/js?id=G-S05E5E5E5E5` |
|
||||
|
||||
**Measurement ID** is found in your Google Analytics property under **Admin → Data Streams → Web → Measurement ID**.
|
||||
|
||||
**Script Host** is the full URL of the gtag.js script to load, including the `?id=` query parameter. Leave it empty to use Google's default CDN (`https://www.googletagmanager.com/gtag/js?id=<your-id>`). Set a custom URL if you proxy the script through your own domain, e.g. `https://your-proxy.example.com/gtag/js?id=G-XXXXXXXXXX`.
|
||||
|
||||
Leave **Transport URL** empty to send hits directly to Google Analytics.
|
||||
|
||||
> [!TIP]
|
||||
> Ad blockers commonly block requests to `www.googletagmanager.com` and `www.google-analytics.com`. Use [Saki](https://saki.rajnandan.com/) — a self-hostable Nginx proxy — to route through your own domain:
|
||||
>
|
||||
> - **Script Host**: `https://saki.rajnandan.com/tg/script.js?id=G-XXXXXXXXXX`
|
||||
> - **Transport URL**: `https://saki.rajnandan.com/an/`
|
||||
|
||||
---
|
||||
|
||||
## Plausible {#plausible}
|
||||
|
||||
Privacy-friendly analytics with no cookies.
|
||||
|
||||
| Field | Required | Example |
|
||||
| ------------- | -------- | ---------------------------------------------------------------- |
|
||||
| Domain | Yes | `kener.ing` |
|
||||
| API | Yes | `https://plausible.io/api/event` |
|
||||
| Script Source | Yes | `https://plausible.io/js/script.pageview-props.tagged-events.js` |
|
||||
|
||||
**Domain** must match exactly what you registered at plausible.io. For self-hosted Plausible, replace the **API** and **Script Source** URLs with your instance's host.
|
||||
|
||||
---
|
||||
|
||||
## Mixpanel {#mixpanel}
|
||||
|
||||
| Field | Required | Example |
|
||||
| ------------- | -------- | -------------------------- |
|
||||
| Project Token | Yes | `abc123def456` |
|
||||
| API Host | No | `https://api.mixpanel.com` |
|
||||
|
||||
Find your **Project Token** in Mixpanel under **Settings → Project Settings**. Leave **API Host** empty to use the default Mixpanel endpoint.
|
||||
|
||||
> [!TIP]
|
||||
> To bypass ad blockers, use [Saki](https://saki.rajnandan.com/). Set **API Host** to `https://saki.rajnandan.com/mxa/`.
|
||||
|
||||
---
|
||||
|
||||
## Amplitude {#amplitude}
|
||||
|
||||
| Field | Required | Example |
|
||||
| ----------------- | -------- | -------------------------------------- |
|
||||
| Amplitude API Key | Yes | `a1b2c3d4e5f6...` |
|
||||
| Server URL | No | `https://api2.amplitude.com/2/httpapi` |
|
||||
|
||||
Find your **Amplitude API Key** under **Settings → Projects → [Your Project] → General**. Leave **Server URL** empty to use Amplitude's default. Use `https://api.eu.amplitude.com/2/httpapi` for EU data residency.
|
||||
|
||||
> [!TIP]
|
||||
> To bypass ad blockers, use [Saki](https://saki.rajnandan.com/). Set **Server URL** to `https://saki.rajnandan.com/aapi/2/httpapi`.
|
||||
|
||||
---
|
||||
|
||||
## Microsoft Clarity {#microsoft-clarity}
|
||||
|
||||
| Field | Required | Example |
|
||||
| ---------- | -------- | ------------ |
|
||||
| Project ID | Yes | `abc1234xyz` |
|
||||
|
||||
Find your **Project ID** in Microsoft Clarity under **Settings → Overview → Tracking Code** (the value after `clarity.ms/tag/`).
|
||||
|
||||
---
|
||||
|
||||
## Umami {#umami}
|
||||
|
||||
Self-hostable, cookie-free analytics.
|
||||
|
||||
| Field | Required | Example |
|
||||
| ---------- | -------- | -------------------------------------- |
|
||||
| Website ID | Yes | `5e1c3b29-3f7d-4f2a-8e6b-d3f1a2b3c4d5` |
|
||||
| Script URL | Yes | `https://cloud.umami.is/script.js` |
|
||||
|
||||
Find your **Website ID** in Umami under **Settings → Websites**. For self-hosted Umami, replace the **Script URL** with your instance URL, e.g. `https://umami.example.com/script.js`.
|
||||
|
||||
---
|
||||
|
||||
## PostHog {#posthog}
|
||||
|
||||
Product analytics with session recordings.
|
||||
|
||||
| Field | Required | Example |
|
||||
| -------- | -------- | -------------------------- |
|
||||
| API Key | Yes | `phc_xxxxxxxxxxxxxxxxxxxx` |
|
||||
| API Host | Yes | `https://us.i.posthog.com` |
|
||||
|
||||
Find your **API Key** in PostHog under **Settings → Project → Project API key**. Set **API Host** to `https://eu.i.posthog.com` for the EU cloud, or your self-hosted PostHog URL.
|
||||
|
||||
> [!TIP]
|
||||
> To bypass ad blockers, use [Saki](https://saki.rajnandan.com/). Set **API Host** to `https://saki.rajnandan.com/pha/`.
|
||||
|
||||
---
|
||||
|
||||
## Enable a provider {#enable-a-provider}
|
||||
|
||||
1. Go to **Manage → Analytics Providers**.
|
||||
2. Select the provider from the left panel.
|
||||
3. Fill in the required fields.
|
||||
4. Set **Status** to **Enable**.
|
||||
5. Click **Save Changes**.
|
||||
|
||||
A green dot next to the provider name indicates it is active on the public site.
|
||||
|
||||
> [!NOTE]
|
||||
> Changes take effect immediately — no restart required.
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: v4.0.19 Changelog
|
||||
description: See what's new in Kener v4.0.19, including new features, improvements, and bug fixes
|
||||
---
|
||||
|
||||
## New features {#new-features}
|
||||
|
||||
### Site-level SEO meta tags {#site-level-seo}
|
||||
|
||||
Admins can now set a custom **meta title** and **meta description** for the entire status page from **Manage → Site Configurations → Social Preview & SEO**. These values are used as defaults for all pages and control how the site appears in search engine results and social link previews.
|
||||
|
||||
Both fields are optional — leave them empty to use the existing page title and header as defaults.
|
||||
|
||||
| Field | Effect |
|
||||
| :----------------- | :---------------------------------------------------- |
|
||||
| `Meta Title` | Sets the `<title>` tag shown in search results |
|
||||
| `Meta Description` | Sets the `<meta name="description">` tag for snippets |
|
||||
|
||||
The **social preview image** upload has been merged into this same card. A single Save button persists the image, title, and description together.
|
||||
|
||||
### Page-level SEO meta tags {#page-level-seo}
|
||||
|
||||
Each page now supports its own **meta title**, **meta description**, and **social preview image** via **Manage → Pages → [page] → Social Preview & SEO**. These override the site-level defaults for that specific page.
|
||||
|
||||
The override hierarchy is:
|
||||
|
||||
1. **Page-level** setting from `page_settings_json` (if set)
|
||||
2. **Site-level** setting from Site Configurations (if set)
|
||||
3. **Auto-generated** defaults (page title + site name, page header)
|
||||
|
||||
| Meta tag | Page-level key | Site-level fallback | Auto fallback |
|
||||
| :--------------------- | :----------------------- | :-------------------- | :-------------------------- |
|
||||
| `<title>` | `metaPageTitle` | `metaSiteTitle` | `{page_title} - {siteName}` |
|
||||
| `<meta description>` | `metaPageDescription` | `metaSiteDescription` | Page header text |
|
||||
| `<meta og:image>` | `socialPagePreviewImage` | `socialPreviewImage` | None |
|
||||
| `<meta twitter:image>` | `socialPagePreviewImage` | `socialPreviewImage` | None |
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: v4.0.20 Changelog
|
||||
description: See what's new in Kener v4.0.20, including new features, improvements, and bug fixes
|
||||
---
|
||||
|
||||
## New features {#new-features}
|
||||
|
||||
### Sitemap configuration {#sitemap-configuration}
|
||||
|
||||
Admins can now configure how `sitemap.xml` is generated from **Manage → Site Configurations → Sitemap**. Three modes are available:
|
||||
|
||||
| Mode | Behaviour |
|
||||
| :--------- | :--------------------------------------------------------------------------------------------------------- |
|
||||
| **Auto** | Automatically includes all pages, visible active monitors, and current/previous month event pages |
|
||||
| **Manual** | Only the URLs you explicitly add are included |
|
||||
| **Off** | Returns a 404 for `/sitemap.xml` |
|
||||
|
||||
In **Auto** mode you can also add extra manual URLs alongside the auto-generated ones. The sitemap URL is shown in the admin UI for easy copying.
|
||||
|
||||
### HEIC/HEIF image support {#heic-heif-support}
|
||||
|
||||
All image upload fields — logo, favicon, social preview, page logo, monitor image — now accept **HEIC** and **HEIF** formats (common on iPhones). Uploaded HEIC/HEIF images are automatically converted to JPEG server-side using `heic-convert` before processing with sharp.
|
||||
|
||||
The default `BODY_SIZE_LIMIT` has been increased to **3 MB** (from the SvelteKit default of 512 KB) to accommodate larger image uploads. You can override this via the `BODY_SIZE_LIMIT` environment variable.
|
||||
|
||||
## Improvements {#improvements}
|
||||
|
||||
### Open Graph & Twitter Card meta tags {#og-twitter-meta}
|
||||
|
||||
All public-facing pages now emit complete Open Graph and Twitter Card meta tags:
|
||||
|
||||
- **Status pages** (`/`, `/[page_path]`) — `og:title`, `og:description`, `og:type`, `og:image`, `twitter:card`, `twitter:image`
|
||||
- **Incident pages** — article-type OG tags with the first comment as description
|
||||
- **Maintenance pages** — article-type OG tags with the maintenance description
|
||||
- **Event pages** — OG tags with month-specific title and description
|
||||
- **Monitor pages** — OG tags with monitor name and status
|
||||
|
||||
### Improved SEO semantics {#seo-semantics}
|
||||
|
||||
- Page headers and incident/maintenance titles are now wrapped in proper `<h1>`/`<h2>` HTML heading tags for better SEO structure.
|
||||
- `robots` meta tag (`noindex, nofollow`) added to admin, account, and embed layouts to prevent search engines from indexing private pages.
|
||||
- `robots.txt` expanded with explicit `Allow`/`Disallow` rules and entries for AI crawlers (GPTBot, ClaudeBot, PerplexityBot, Google-Extended, etc.).
|
||||
|
||||
### Page-level SEO field key fix {#page-seo-key-fix}
|
||||
|
||||
Page-level SEO settings now use the correct keys (`metaPageTitle`, `metaPageDescription`, `socialPagePreviewImage`) consistently. Previously, the dashboard controller read `metaTitle`/`metaDescription`/`socialPreviewImage` which didn't match what the page settings form saved. Pages with zero monitors also now correctly return SEO metadata.
|
||||
|
||||
### Separate save buttons for page settings {#separate-save-buttons}
|
||||
|
||||
The page settings UI now has separate **Save** buttons and loading states for Display settings and SEO settings, so saving one section doesn't affect the other.
|
||||
|
||||
## Bug fixes {#bug-fixes}
|
||||
|
||||
- Fixed image upload error handling on pages — non-OK responses are now caught before attempting to parse JSON.
|
||||
- Removed unused `MonitorAlerting.svelte` component and stale `llms.txt`/`sitemap.xml` server handlers.
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: v4.0.22 Changelog
|
||||
description: See what's new in Kener v4.0.22, including new features, improvements, and bug fixes
|
||||
---
|
||||
|
||||
## New features {#new-features}
|
||||
|
||||
> [!NOTE]
|
||||
> The migration for sqlite3 was broken in the release 4.0.21. So it has been fixed in this release.
|
||||
|
||||
### Multi-monitor alert configurations {#multi-monitor-alerts}
|
||||
|
||||
Alert configurations can now be applied to **multiple monitors** at once. Previously each alert config was tied to a single monitor — creating the same alert for several monitors required duplicating it.
|
||||
|
||||
- A searchable multi-select UI replaces the single-monitor dropdown when creating or editing alerts.
|
||||
- Alert logs show which specific monitor triggered each alert.
|
||||
- Existing single-monitor configs are automatically migrated to the new schema.
|
||||
|
||||
New database migration adds a `monitor_alerts_config_monitors` junction table and a `monitor_tag` column on `monitor_alerts_v2` to track per-monitor trigger history.
|
||||
|
||||
### Upcoming maintenances on status pages {#upcoming-maintenances}
|
||||
|
||||
Upcoming scheduled maintenance events are now displayed on the home page, custom page routes, and individual monitor detail pages. This gives visitors advance notice of planned work.
|
||||
|
||||
Visibility is controlled by the existing **Event Display Settings → Maintenances → Upcoming** toggle in **Manage → Site Configurations**.
|
||||
|
||||
### Maintenance subscriber notifications {#maintenance-notifications}
|
||||
|
||||
Maintenance events can now trigger subscriber notifications at each lifecycle stage: **created**, **reminder**, **started**, and **ended**. Configure which events send notifications in **Manage → Site Configurations → Maintenance Notification Settings**.
|
||||
|
||||
| Event type | When sent | Default |
|
||||
| :--------- | :--------------------------- | :------ |
|
||||
| Created | New event is generated | Off |
|
||||
| Reminder | Event enters READY state | On |
|
||||
| Started | Event enters ONGOING state | On |
|
||||
| Ended | Event enters COMPLETED state | On |
|
||||
|
||||
The **reminder buffer** (default 1 hour) controls how far before the start time the READY transition and reminder notification happen.
|
||||
|
||||
### Analytics documentation {#analytics-docs}
|
||||
|
||||
A new [Analytics](/docs/v4/analytics) documentation page covers all seven supported providers — Google Analytics, Plausible, Mixpanel, Amplitude, Microsoft Clarity, Umami, and PostHog — with configuration tables, setup instructions, and ad-blocker proxy tips.
|
||||
|
||||
## Improvements {#improvements}
|
||||
|
||||
### Google Tag Manager transport URL and script host {#gtm-transport-url}
|
||||
|
||||
The Google Tag Manager integration now supports two new optional fields:
|
||||
|
||||
- **Transport URL** — custom endpoint for analytics hits (useful for bypassing ad blockers with a proxy like [Saki](https://github.com/nicholasgasior/saki)).
|
||||
- **Script Host** — custom URL for the gtag.js script instead of the default `googletagmanager.com`.
|
||||
|
||||
Configure both in **Manage → Analytics Providers → Google Analytics**.
|
||||
|
||||
### Improved TCP and API monitor error handling {#tcp-api-error-handling}
|
||||
|
||||
- **TCP monitors** now collect all host connection failure messages and report them as a combined error.
|
||||
- **API monitors** now create an HTTPS agent with keep-alive and socket pooling, distinguish timeout errors more accurately (`ECONNABORTED` vs message-based), and truncate eval errors to 200 characters.
|
||||
|
||||
### "Included Monitors" shows count {#included-monitors-count}
|
||||
|
||||
The group monitor popover and related components now display the number of included monitors — e.g. "Included Monitors (5)" — making it easier to see group size at a glance. All locale files updated with the `%count` placeholder.
|
||||
|
||||
### Czech and Slovak translation updates {#czech-slovak-translations}
|
||||
|
||||
Updated Czech (`cs.json`) and Slovak (`sk.json`) translations with new and corrected strings.
|
||||
|
||||
## Bug fixes {#bug-fixes}
|
||||
|
||||
- Fixed custom CSS and custom fonts documentation guide links pointing to incorrect URLs.
|
||||
- Removed unused `<svelte:head>` elements and stale metadata from documentation layouts.
|
||||
- Removed `knip.json` — the project now uses default knip configuration.
|
||||
|
||||
## Security {#security}
|
||||
|
||||
Patched critical and high-severity vulnerabilities by adding `overrides` for `fast-xml-parser`, `rollup`, `undici`, `minimatch`, `devalue`, `dompurify`, `cookie`, and `mailparser`. Updated `@sveltejs/kit` to ^2.53.3 and `svelte` to ^5.53.5.
|
||||
@@ -31,7 +31,7 @@ Response: Array of todo objects with `completed` boolean field.
|
||||
### Eval function:
|
||||
|
||||
```javascript
|
||||
(async function (statusCode, responseTime, responseRaw) {
|
||||
async function (statusCode, responseTime, responseRaw) {
|
||||
if (statusCode !== 200) {
|
||||
return { status: "DOWN", latency: responseTime }
|
||||
}
|
||||
@@ -58,7 +58,7 @@ Response: Array of todo objects with `completed` boolean field.
|
||||
}
|
||||
|
||||
return { status: "DOWN", latency: responseTime }
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## POST request body with $SECRET_PARAM (no eval) {#post-secret-param-body}
|
||||
@@ -81,7 +81,7 @@ SECRET_PARAM=your_real_secret_value
|
||||
## Cheerio HTML content check {#cheerio-html-check-eval}
|
||||
|
||||
```javascript
|
||||
(async function (statusCode, responseTime, responseRaw, modules) {
|
||||
async function (statusCode, responseTime, responseRaw, modules) {
|
||||
if (statusCode !== 200) {
|
||||
return { status: "DOWN", latency: responseTime }
|
||||
}
|
||||
@@ -99,5 +99,5 @@ SECRET_PARAM=your_real_secret_value
|
||||
status: status ? "UP" : "DOWN",
|
||||
latency: responseTime
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
---
|
||||
title: Kener vs Other Status Pages
|
||||
description: Compare Kener with Upptime, Gatus, cState, Cachet, Uptime Kuma, Atlassian Statuspage, Better Stack, Instatus, Hyperping, and PagerDuty
|
||||
---
|
||||
|
||||
Choosing the right status page tool depends on your monitoring needs, budget, and how much control you want. This guide compares Kener with popular open-source alternatives and SaaS products.
|
||||
|
||||
## Quick Comparison Table {#quick-comparison}
|
||||
|
||||
| Feature | Kener | Upptime | Gatus | cState | Cachet | Uptime Kuma |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **Type** | Open source | Open source | Open source | Open source | Open source | Open source |
|
||||
| **Built with** | SvelteKit + Node.js | GitHub Actions | Go | Hugo (static) | PHP / Laravel | Node.js |
|
||||
| **Self-hosted** | Yes | GitHub-only | Yes | Static hosting | Yes | Yes |
|
||||
| **Admin dashboard** | Yes | No (GitHub UI) | No (YAML config) | No (CMS/CLI) | Yes | Yes |
|
||||
| **Active monitoring** | Yes (11 types) | HTTP only | Yes (8+ types) | No | No | Yes (10+ types) |
|
||||
| **Incident management** | Yes (full lifecycle) | GitHub Issues | No | Markdown files | Yes | No |
|
||||
| **Maintenance windows** | Yes (with RRULE) | Yes (basic) | No | Markdown files | Yes (basic) | Yes (basic) |
|
||||
| **Alerting** | Yes (4 channels) | Yes (9+ channels) | Yes (10+ channels) | No | No | Yes (78+ channels) |
|
||||
| **Subscriber notifications** | Yes (email) | No | No | No | Yes (email) | No |
|
||||
| **Multi-database** | SQLite, PostgreSQL, MySQL | None (file-based) | SQLite, PostgreSQL | None (static) | MySQL, PostgreSQL, SQLite | SQLite |
|
||||
| **REST API** | Yes (17+ endpoints) | Read-only | No | Read-only | Yes | Yes |
|
||||
| **RBAC** | Yes (3 roles) | No | No | No | Yes (basic) | No |
|
||||
| **i18n** | Yes | Yes | No | Yes | Yes (10+ languages) | Yes (30+ languages) |
|
||||
| **Custom theming** | Yes (CSS, colors, fonts) | Basic | No | Hugo themes | Yes (Bootstrap) | Basic |
|
||||
| **Embeddable widgets** | Yes (badges, bars, charts) | No | No | Badges only | Yes (badges) | Yes (badges) |
|
||||
| **License** | MIT | MIT | Apache 2.0 | MIT | BSD-3 | MIT |
|
||||
|
||||
## Open Source Alternatives {#open-source}
|
||||
|
||||
### Upptime {#upptime}
|
||||
|
||||
[Upptime](https://github.com/upptime/upptime) runs entirely on GitHub infrastructure — Actions for monitoring, Issues for incidents, and Pages for the status page. It requires zero server setup.
|
||||
|
||||
**Best for:** Teams already on GitHub who want a no-cost, zero-maintenance solution.
|
||||
|
||||
**Limitations:**
|
||||
- HTTP monitoring only (no TCP, DNS, SSL, Ping, gRPC, SQL)
|
||||
- 5-minute minimum check interval (GitHub Actions constraint)
|
||||
- No admin dashboard — everything managed through GitHub UI and config files
|
||||
- No subscriber notifications or alerting beyond GitHub integrations
|
||||
- No database-backed data storage
|
||||
|
||||
### Gatus {#gatus}
|
||||
|
||||
[Gatus](https://github.com/TwiN/gatus) is a developer-oriented monitoring tool written in Go with a lightweight status page UI.
|
||||
|
||||
**Best for:** DevOps teams who prefer YAML-based configuration and want minimal resource usage (10–30 MB RAM).
|
||||
|
||||
**Limitations:**
|
||||
- No admin UI — all configuration via YAML files
|
||||
- No incident management or maintenance scheduling
|
||||
- No subscriber notifications
|
||||
- No REST API for external integrations
|
||||
- No embeddable widgets or badges
|
||||
|
||||
### cState {#cstate}
|
||||
|
||||
[cState](https://github.com/cstate/cstate) is a static status page built with Hugo. It generates a fast, serverless site that can be hosted anywhere.
|
||||
|
||||
**Best for:** Teams that only need incident communication without active monitoring.
|
||||
|
||||
**Limitations:**
|
||||
- No active monitoring — it is purely a communication page
|
||||
- Incidents created via Markdown files or CMS
|
||||
- No alerting, no subscriber notifications
|
||||
- No admin dashboard
|
||||
- No database or API beyond read-only static JSON
|
||||
|
||||
### Cachet {#cachet}
|
||||
|
||||
[Cachet](https://github.com/cachethq/cachet) is a PHP/Laravel status page with a web dashboard, metric graphs, and subscriber notifications.
|
||||
|
||||
**Best for:** PHP teams who want a traditional status page with a dashboard.
|
||||
|
||||
**Limitations:**
|
||||
- No built-in active monitoring (requires external tools)
|
||||
- Development has been slow — major rewrite has been in progress for years
|
||||
- PHP/Laravel dependency stack
|
||||
- No modern UI framework
|
||||
- Limited alerting capabilities
|
||||
|
||||
### Uptime Kuma {#uptime-kuma}
|
||||
|
||||
[Uptime Kuma](https://github.com/louislam/uptime-kuma) is a popular self-hosted monitoring tool with a polished UI and 78+ notification integrations.
|
||||
|
||||
**Best for:** Teams that need a monitoring dashboard with broad notification support.
|
||||
|
||||
**Limitations:**
|
||||
- Status page is secondary to the monitoring dashboard — limited customization
|
||||
- No incident lifecycle management (investigating → identified → resolved)
|
||||
- No subscriber notifications
|
||||
- No maintenance scheduling with recurring rules
|
||||
- No multi-database support (SQLite only)
|
||||
- No REST API for automation
|
||||
- No RBAC or multi-user team management
|
||||
- No embeddable status widgets beyond basic badges
|
||||
|
||||
## SaaS Alternatives {#saas}
|
||||
|
||||
### Atlassian Statuspage {#statuspage}
|
||||
|
||||
[Statuspage.io](https://www.atlassian.com/software/statuspage) is the most widely recognized SaaS status page, used by major companies.
|
||||
|
||||
| Plan | Price | Subscribers | Team Members |
|
||||
|---|---|---|---|
|
||||
| Free | $0/mo | 100 | 2 |
|
||||
| Hobby | $29/mo | 250 | 5 |
|
||||
| Startup | $99/mo | 1,000 | 10 |
|
||||
| Business | $399/mo | 5,000 | 25 |
|
||||
| Enterprise | $1,499/mo | 25,000 | 50 |
|
||||
|
||||
**Key differences from Kener:**
|
||||
- No built-in active monitoring — requires external tools (PagerDuty, Datadog, etc.)
|
||||
- Subscriber limits per plan (Kener has no subscriber caps)
|
||||
- Limited customization on lower tiers (custom CSS only on Startup+)
|
||||
- No self-hosting option — data stays on Atlassian infrastructure
|
||||
|
||||
### Better Stack {#betterstack}
|
||||
|
||||
[Better Stack](https://betterstack.com) combines uptime monitoring, status pages, incident management, and log management.
|
||||
|
||||
**Key differences from Kener:**
|
||||
- Free tier includes 10 monitors with 3-minute intervals
|
||||
- Paid plans can get expensive for teams
|
||||
- Includes log management (beyond status page scope)
|
||||
- No self-hosting — fully managed SaaS
|
||||
- Vendor lock-in for monitoring data
|
||||
|
||||
### Instatus {#instatus}
|
||||
|
||||
[Instatus](https://instatus.com) focuses on fast, beautifully designed status pages using Jamstack architecture.
|
||||
|
||||
**Key differences from Kener:**
|
||||
- Starts at ~$15–20/mo
|
||||
- 30+ language support
|
||||
- No built-in monitoring — integrates with external providers (UptimeRobot, Datadog, Pingdom, etc.)
|
||||
- Static pages served via CDN (fast but no real-time updates)
|
||||
- No self-hosting option
|
||||
|
||||
### Hyperping {#hyperping}
|
||||
|
||||
[Hyperping](https://hyperping.com) offers uptime monitoring with status pages and on-call scheduling.
|
||||
|
||||
| Plan | Price | Monitors | Status Pages |
|
||||
|---|---|---|---|
|
||||
| Free | $0/mo | 5 | 1 (limited) |
|
||||
| Essentials | $24/mo | 50 | 1 |
|
||||
| Pro | $74/mo | 100 | Multiple |
|
||||
|
||||
**Key differences from Kener:**
|
||||
- 30-second check intervals on paid plans
|
||||
- Includes on-call scheduling (Kener does not)
|
||||
- No self-hosting option
|
||||
- Monitor limits per plan
|
||||
|
||||
### PagerDuty Status Page {#pagerduty}
|
||||
|
||||
[PagerDuty](https://www.pagerduty.com/platform/business-ops/status-pages/) offers status pages as an add-on to their incident management platform.
|
||||
|
||||
**Key differences from Kener:**
|
||||
- Status page is a $89/mo add-on to existing PagerDuty plans ($21–41/user/mo)
|
||||
- Subscriber limits (250–500 depending on plan)
|
||||
- Primarily an incident management tool — status page is secondary
|
||||
- No self-hosting, significant vendor lock-in
|
||||
- Annual renewal price increases reported (10–15%)
|
||||
|
||||
## Why Kener {#why-kener}
|
||||
|
||||
| Capability | Kener | Typical SaaS | Typical OSS |
|
||||
|---|---|---|---|
|
||||
| **11 monitor types** (API, TCP, DNS, SSL, Ping, SQL, gRPC, Heartbeat, GameDig, Group) | Yes | Varies (often HTTP-only or external) | 1–5 types |
|
||||
| **Full incident lifecycle** (investigating → identified → monitoring → resolved) | Yes | Yes | Rare |
|
||||
| **Recurring maintenance** (RRULE patterns) | Yes | Basic scheduling | Rare |
|
||||
| **Auto-generated incidents** from alerts | Yes | Some | No |
|
||||
| **Subscriber email notifications** | Yes (unlimited) | Yes (capped by plan) | Rare |
|
||||
| **RBAC** (Admin, Editor, Member) | Yes | Yes (paid tiers) | Rare |
|
||||
| **Multi-database** (SQLite, PostgreSQL, MySQL) | Yes | N/A (managed) | Usually 1 |
|
||||
| **Embeddable widgets** (status bars, latency charts, badges) | Yes | Some | Basic badges |
|
||||
| **Custom CSS/JS injection** | Yes | Paid tiers only | Varies |
|
||||
| **Custom fonts and theming** | Yes | Limited | Varies |
|
||||
| **Multiple status pages** | Yes | Paid tiers only | Rare |
|
||||
| **REST API** (17+ endpoints) | Yes | Yes | Limited |
|
||||
| **i18n support** | Yes | Some | Varies |
|
||||
| **Self-hosted, your data** | Yes | No | Yes |
|
||||
| **Cost** | Free (open source) | $29–1,499+/mo | Free |
|
||||
|
||||
### Kener is a good fit if you need {#good-fit}
|
||||
|
||||
- **Built-in monitoring + status page** in one tool (no external monitoring service required)
|
||||
- **Full control** over your data and infrastructure
|
||||
- **No subscriber or monitor caps** — scale without upgrading plans
|
||||
- **Rich incident management** with auto-generation from alerts
|
||||
- **Recurring maintenance windows** with complex scheduling (RRULE)
|
||||
- **Multiple database options** to match your existing infrastructure
|
||||
- **Embeddable widgets** for external dashboards or documentation sites
|
||||
- **Team management** with role-based access control
|
||||
|
||||
> [!TIP]
|
||||
> Kener provides monitoring + status page + incident management + alerting in a single self-hosted package. Most open-source alternatives cover only one or two of these, and SaaS tools charge per feature tier.
|
||||
@@ -26,13 +26,13 @@ Connection errors and timeouts return **DOWN**.
|
||||
|
||||
## Configuration fields {#configuration-fields}
|
||||
|
||||
| Field | Type | Default | Notes |
|
||||
| :-------- | :-------- | :------ | :--------------------------------------------- |
|
||||
| `host` | `string` | — | Required |
|
||||
| `port` | `number` | `50051` | Required |
|
||||
| `service` | `string` | `""` | Fully qualified service name; empty = overall |
|
||||
| `tls` | `boolean` | `false` | Use TLS credentials |
|
||||
| `timeout` | `number` | `10000` | Request deadline in ms |
|
||||
| Field | Type | Default | Notes |
|
||||
| :-------- | :-------- | :------ | :-------------------------------------------- |
|
||||
| `host` | `string` | — | Required |
|
||||
| `port` | `number` | `50051` | Required |
|
||||
| `service` | `string` | `""` | Fully qualified service name; empty = overall |
|
||||
| `tls` | `boolean` | `false` | Use TLS credentials |
|
||||
| `timeout` | `number` | `10000` | Request deadline in ms |
|
||||
|
||||
## Example {#example}
|
||||
|
||||
|
||||
@@ -64,6 +64,23 @@ Choose one layout:
|
||||
|
||||
Click **Save Preferences** after changes.
|
||||
|
||||
## Social preview and SEO {#social-preview-and-seo}
|
||||
|
||||
Each page can override the site-level SEO defaults set in [Site Configuration](/docs/v4/setup/site-configuration#social-preview-and-seo).
|
||||
|
||||
| Field | Description |
|
||||
| ---------------------- | ------------------------------------------------------------- |
|
||||
| `Meta Title` | Custom `<title>` and `og:title` for this page. |
|
||||
| `Meta Description` | Custom `<meta name="description">` and `og:description`. |
|
||||
| `Social Preview Image` | Custom `og:image` and `twitter:image` shown in link previews. |
|
||||
|
||||
When a field is left empty, the site-level value is used. When the site-level value is also empty, the page title and header are used as automatic fallbacks.
|
||||
|
||||
**Override hierarchy**: Page setting → Site setting → Auto-generated from page title/header.
|
||||
|
||||
> [!TIP]
|
||||
> Upload a 1200×630 image for best results across social platforms.
|
||||
|
||||
## Delete a page {#delete-a-page}
|
||||
|
||||
Non-home pages can be deleted from **Danger Zone**.
|
||||
|
||||
@@ -178,6 +178,25 @@ NODE_ENV=production
|
||||
- Controls logging verbosity
|
||||
- Enables/disables development-only features
|
||||
|
||||
### BODY_SIZE_LIMIT {#body-size-limit}
|
||||
|
||||
**Purpose**: Maximum allowed request body size. This is a SvelteKit setting that controls the largest payload the server will accept.
|
||||
|
||||
**Default**: `3M` (3 megabytes)
|
||||
|
||||
**When to Change**: Increase if you need to upload larger images through the admin panel or send larger API payloads.
|
||||
|
||||
**Format**: Number followed by unit — `K` (kilobytes), `M` (megabytes), or `Infinity` for no limit.
|
||||
|
||||
**Example**:
|
||||
|
||||
```bash
|
||||
BODY_SIZE_LIMIT=10M
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The Docker image sets `BODY_SIZE_LIMIT=3M` by default. Override it by passing the variable to your container.
|
||||
|
||||
## Integration Variables {#integration-variables}
|
||||
|
||||
For detailed configuration of these integrations, see their dedicated documentation pages.
|
||||
|
||||
@@ -12,18 +12,22 @@ Use **Manage → Site Configurations** to control identity, navigation, monitor
|
||||
3. Configure **Navigation Menu**.
|
||||
4. Set **Monitor Sub Menu Options**.
|
||||
5. Configure **Global Page Visibility Settings**.
|
||||
6. Configure **Data Retention Policy**.
|
||||
7. Configure **Event Display Settings**.
|
||||
6. Configure **Social Preview & SEO**.
|
||||
7. Configure **Data Retention Policy**.
|
||||
8. Configure **Event Display Settings**.
|
||||
9. Configure **Maintenance Notification Settings**.
|
||||
|
||||
## Runtime impact map {#runtime-impact-map}
|
||||
|
||||
| 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` |
|
||||
| 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` |
|
||||
| Maintenance notifications | `globalMaintenanceNotificationSettings` | Controls which maintenance lifecycle events notify subscribers and reminder buffer timing |
|
||||
| Social preview & SEO | `metaSiteTitle`, `metaSiteDescription`, `socialPreviewImage` | Default `<title>`, `og:title`, `<meta description>`, `og:description`, `og:image` for all pages |
|
||||
|
||||
## Monitor sub menu options {#monitor-sub-menu-options}
|
||||
|
||||
@@ -80,6 +84,40 @@ This affects:
|
||||
- event sections on status pages
|
||||
- notifications payload API used by the UI
|
||||
|
||||
## Maintenance notification settings {#maintenance-notification-settings}
|
||||
|
||||
`globalMaintenanceNotificationSettings` controls which maintenance event lifecycle transitions send subscriber notifications.
|
||||
|
||||
### Event types {#event-types}
|
||||
|
||||
| Event type | Trigger | Default |
|
||||
| ---------- | ------------------------------- | ------- |
|
||||
| `created` | New maintenance event generated | Off |
|
||||
| `reminder` | Event enters READY state | On |
|
||||
| `started` | Event enters ONGOING state | On |
|
||||
| `ended` | Event enters COMPLETED state | On |
|
||||
|
||||
### Reminder buffer {#reminder-buffer}
|
||||
|
||||
`reminder_buffer_hours` (default `1`, minimum `1`) sets how many hours before the event start time the status transitions from SCHEDULED to READY.
|
||||
|
||||
See [Maintenance Events → Subscriber Notifications](/docs/v4/maintenances/events#subscriber-notifications) for the full lifecycle flow.
|
||||
|
||||
## Social preview and SEO {#social-preview-and-seo}
|
||||
|
||||
The **Social Preview & SEO** card sets site-wide defaults for meta tags used in search engines and link previews.
|
||||
|
||||
| Field | Stored key | Meta tags affected |
|
||||
| ---------------------- | --------------------- | --------------------------------------------- |
|
||||
| `Meta Title` | `metaSiteTitle` | `<title>`, `og:title` |
|
||||
| `Meta Description` | `metaSiteDescription` | `<meta name="description">`, `og:description` |
|
||||
| `Social Preview Image` | `socialPreviewImage` | `og:image`, `twitter:image` |
|
||||
|
||||
These values are used as defaults for every page. Individual pages can override them — see [Pages → Social Preview & SEO](/docs/v4/pages#social-preview-and-seo).
|
||||
|
||||
> [!TIP]
|
||||
> Upload a 1200×630 image for best results across social platforms.
|
||||
|
||||
## Verify changes {#verify-changes}
|
||||
|
||||
- Update site name/logo/nav and refresh home page.
|
||||
@@ -90,3 +128,5 @@ This affects:
|
||||
- notifications calendar opens page-scoped events for the current month.
|
||||
- Change event display settings and verify incident/maintenance visibility.
|
||||
- Set retention policy and confirm scheduler logs in server output.
|
||||
- Toggle maintenance notification event types and verify subscribers receive (or don't receive) emails at each lifecycle stage.
|
||||
- Set meta title/description and social preview image, then check `<meta>` tags in page source.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<Toaster />
|
||||
|
||||
<svelte:head>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>Kener Status</title>
|
||||
<link rel="icon" href={data.favicon} />
|
||||
{#if data.font?.cssSrc}
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
<Toaster />
|
||||
|
||||
<svelte:head>
|
||||
<title>Kener Status</title>
|
||||
<link rel="icon" href={data.favicon} />
|
||||
{#if data.font?.cssSrc}
|
||||
<link rel="stylesheet" href={data.font.cssSrc} />
|
||||
@@ -44,11 +43,6 @@
|
||||
${data.customCSS || ""}
|
||||
</style>`}
|
||||
<script src={clientResolver(resolve, "/capture.js")}></script>
|
||||
<!-- social preview meta tags -->
|
||||
{#if data.socialPreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
<main class="kener-public">
|
||||
<!-- Nav -->
|
||||
|
||||
@@ -113,7 +113,32 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{(data.pageDetails?.page_title || "Status Page") + " - " + data.siteName}</title>
|
||||
{#if data.metaPageTitle}
|
||||
<title>{data.metaPageTitle}</title>
|
||||
<meta property="og:title" content={data.metaPageTitle} />
|
||||
{:else if data.pageDetails?.page_title}
|
||||
<title>{data.pageDetails.page_title} - {data.siteName}</title>
|
||||
<meta property="og:title" content={data.pageDetails.page_title + " - " + data.siteName} />
|
||||
{:else}
|
||||
<title>{data.siteName} - Status Page</title>
|
||||
<meta property="og:title" content={data.siteName + " - Status Page"} />
|
||||
{/if}
|
||||
{#if data.metaPageDescription}
|
||||
<meta name="description" content={data.metaPageDescription} />
|
||||
<meta property="og:description" content={data.metaPageDescription} />
|
||||
{:else if data.pageDetails?.page_header}
|
||||
<meta name="description" content={data.pageDetails.page_header} />
|
||||
<meta property="og:description" content={data.pageDetails.page_header} />
|
||||
{:else}
|
||||
<meta name="description" content={(data.pageDetails?.page_title || "Status Page") + " - Status Page"} />
|
||||
<meta property="og:description" content={(data.pageDetails?.page_title || "Status Page") + " - Status Page"} />
|
||||
{/if}
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{#if data.socialPagePreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPagePreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPagePreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<!-- page title -->
|
||||
@@ -130,14 +155,16 @@
|
||||
<Item.Root class="px-0 py-0">
|
||||
<Item.Content>
|
||||
{#if data.pageDetails?.page_header}
|
||||
<Item.Title class="text-2xl sm:text-3xl">{data.pageDetails.page_header}</Item.Title>
|
||||
<h1>
|
||||
<Item.Title class="text-2xl sm:text-3xl">{data.pageDetails.page_header}</Item.Title>
|
||||
</h1>
|
||||
{/if}
|
||||
{#if data.pageDetails?.page_subheader}
|
||||
<div class="">
|
||||
<h2 class="">
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||
<SveltePurify html={mdToHTML(data.pageDetails.page_subheader)} />
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
{/if}
|
||||
</Item.Content>
|
||||
</Item.Root>
|
||||
@@ -162,6 +189,15 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.upcomingMaintenances && data.upcomingMaintenances.length > 0}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each data.upcomingMaintenances as maintenance, i (maintenance.id ?? i)}
|
||||
<div class="rounded-3xl border p-3 sm:p-4">
|
||||
<MaintenanceItem {maintenance} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="overflow-hidden rounded-3xl border">
|
||||
<div class={`grid grid-cols-1 ${getGridContainerClass(viewType)}`}>
|
||||
{#each data.monitorTags as tag, i (tag)}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
import { SveltePurify } from "@humanspeak/svelte-purify";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let pageSettings = $derived(data.pageDetails.page_settings);
|
||||
let barCount = $derived.by(() =>
|
||||
data.isMobile
|
||||
@@ -114,7 +113,32 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{(data.pageDetails?.page_title || "Status Page") + " - " + data.siteName}</title>
|
||||
{#if data.metaPageTitle}
|
||||
<title>{data.metaPageTitle}</title>
|
||||
<meta property="og:title" content={data.metaPageTitle} />
|
||||
{:else if data.pageDetails?.page_title}
|
||||
<title>{data.pageDetails.page_title} - {data.siteName}</title>
|
||||
<meta property="og:title" content={data.pageDetails.page_title + " - " + data.siteName} />
|
||||
{:else}
|
||||
<title>{data.siteName} - Status Page</title>
|
||||
<meta property="og:title" content={data.siteName + " - Status Page"} />
|
||||
{/if}
|
||||
{#if data.metaPageDescription}
|
||||
<meta name="description" content={data.metaPageDescription} />
|
||||
<meta property="og:description" content={data.metaPageDescription} />
|
||||
{:else if data.pageDetails?.page_header}
|
||||
<meta name="description" content={data.pageDetails.page_header} />
|
||||
<meta property="og:description" content={data.pageDetails.page_header} />
|
||||
{:else}
|
||||
<meta name="description" content={(data.pageDetails?.page_title || "Status Page") + " - Status Page"} />
|
||||
<meta property="og:description" content={(data.pageDetails?.page_title || "Status Page") + " - Status Page"} />
|
||||
{/if}
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{#if data.socialPagePreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPagePreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPagePreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<!-- page title -->
|
||||
@@ -131,14 +155,16 @@
|
||||
<Item.Root class="px-0 py-0">
|
||||
<Item.Content>
|
||||
{#if data.pageDetails?.page_header}
|
||||
<Item.Title class="text-2xl sm:text-3xl">{data.pageDetails.page_header}</Item.Title>
|
||||
<h1>
|
||||
<Item.Title class="text-2xl sm:text-3xl">{data.pageDetails.page_header}</Item.Title>
|
||||
</h1>
|
||||
{/if}
|
||||
{#if data.pageDetails?.page_subheader}
|
||||
<div class="">
|
||||
<h2 class="">
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||
<SveltePurify html={mdToHTML(data.pageDetails.page_subheader)} />
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
{/if}
|
||||
</Item.Content>
|
||||
</Item.Root>
|
||||
@@ -163,6 +189,15 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.upcomingMaintenances && data.upcomingMaintenances.length > 0}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each data.upcomingMaintenances as maintenance, i (maintenance.id ?? i)}
|
||||
<div class="rounded-3xl border p-3 sm:p-4">
|
||||
<MaintenanceItem {maintenance} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="overflow-hidden rounded-3xl border">
|
||||
<div class={`grid grid-cols-1 ${getGridContainerClass(viewType)}`}>
|
||||
{#each data.monitorTags as tag, i (tag)}
|
||||
|
||||
@@ -159,6 +159,15 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>{currentMonth} - Maintenances & Incidents - {data.siteName}</title>
|
||||
<meta name="description" content={`${currentMonth} maintenances and incidents for ${data.siteName}`} />
|
||||
<meta property="og:title" content={`${currentMonth} - Maintenances & Incidents - ${data.siteName}`} />
|
||||
<meta property="og:description" content={`${currentMonth} maintenances and incidents for ${data.siteName}`} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{#if data.socialPreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
|
||||
@@ -32,7 +32,15 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
||||
|
||||
if (!!analyticsData["analytics.googleTagManager"]) {
|
||||
let id = analyticsData["analytics.googleTagManager"].requirements["Measurement ID"];
|
||||
captureScript = captureScript + ";\n" + gtm.replaceAll("{{id}}", id);
|
||||
let transportUrl = analyticsData["analytics.googleTagManager"].requirements["Transport URL"] || "";
|
||||
let scriptHost = analyticsData["analytics.googleTagManager"].requirements["Script Host"] || "";
|
||||
captureScript =
|
||||
captureScript +
|
||||
";\n" +
|
||||
gtm
|
||||
.replaceAll("{{id}}", id)
|
||||
.replaceAll("{{transport_url}}", transportUrl)
|
||||
.replaceAll("{{script_host}}", scriptHost);
|
||||
}
|
||||
|
||||
if (!!analyticsData["analytics.mixpanel"]) {
|
||||
|
||||
@@ -159,6 +159,15 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>{currentMonth} - Maintenances & Incidents - {data.siteName}</title>
|
||||
<meta name="description" content={`${currentMonth} maintenances and incidents for ${data.siteName}`} />
|
||||
<meta property="og:title" content={`${currentMonth} - Maintenances & Incidents - ${data.siteName}`} />
|
||||
<meta property="og:description" content={`${currentMonth} maintenances and incidents for ${data.siteName}`} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{#if data.socialPreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
|
||||
@@ -20,9 +20,16 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.incident.title + " - " + data.siteName}</title>
|
||||
<!-- meta description -->
|
||||
<meta property="og:title" content={data.incident.title + " - " + data.siteName} />
|
||||
<meta property="og:type" content="article" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{#if data.comments.length > 0}
|
||||
<meta name="description" content={data.comments[0].comment} />
|
||||
<meta property="og:description" content={data.comments[0].comment} />
|
||||
{/if}
|
||||
{#if data.socialPreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
@@ -31,7 +38,9 @@
|
||||
<div class="flex flex-col gap-2 px-4 py-2">
|
||||
<Item.Root class="mb-4 px-0">
|
||||
<Item.Content class="min-w-0 flex-1 px-0">
|
||||
<Item.Title class="text-3xl wrap-break-word">{data.incident.title}</Item.Title>
|
||||
<h1>
|
||||
<Item.Title class="text-3xl wrap-break-word">{data.incident.title}</Item.Title>
|
||||
</h1>
|
||||
</Item.Content>
|
||||
</Item.Root>
|
||||
</div>
|
||||
|
||||
@@ -65,9 +65,16 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.maintenance.title + " - " + data.siteName}</title>
|
||||
<!-- meta description -->
|
||||
<meta property="og:title" content={data.maintenance.title + " - " + data.siteName} />
|
||||
<meta property="og:type" content="article" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{#if data.maintenance.description}
|
||||
<meta name="description" content={data.maintenance.description} />
|
||||
<meta property="og:description" content={data.maintenance.description} />
|
||||
{/if}
|
||||
{#if data.socialPreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
@@ -75,11 +82,10 @@
|
||||
<ThemePlus />
|
||||
<div class="flex flex-col gap-2 px-4 py-2">
|
||||
<Item.Root class="mb-4 flex-col items-start px-0 sm:flex-row sm:items-center">
|
||||
<Item.Media>
|
||||
<MaintenanceIcon class="stroke-maintenance size-8" />
|
||||
</Item.Media>
|
||||
<Item.Content class="min-w-0 flex-1 px-0">
|
||||
<Item.Title class="text-3xl wrap-break-word">{data.maintenance.title}</Item.Title>
|
||||
<h1>
|
||||
<Item.Title class="text-3xl wrap-break-word">{data.maintenance.title}</Item.Title>
|
||||
</h1>
|
||||
</Item.Content>
|
||||
</Item.Root>
|
||||
</div>
|
||||
|
||||
@@ -35,9 +35,17 @@ export const load: PageServerLoad = async ({ params, parent }) => {
|
||||
|
||||
const monitorTags = [monitor_tag];
|
||||
|
||||
const [ongoingIncidents, ongoingMaintenances] = await Promise.all([
|
||||
const eventSettings = parentData.eventDisplaySettings;
|
||||
const [ongoingIncidents, ongoingMaintenances, upcomingMaintenances] = await Promise.all([
|
||||
GetOngoingIncidentsForMonitorList(monitorTags),
|
||||
GetOngoingMaintenanceEventsForMonitorList(monitorTags),
|
||||
eventSettings.maintenances.enabled && eventSettings.maintenances.upcoming.show
|
||||
? GetUpcomingMaintenanceEventsForMonitorList(
|
||||
monitorTags,
|
||||
eventSettings.maintenances.upcoming.maxCount,
|
||||
eventSettings.maintenances.upcoming.daysInFuture,
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
//last known status
|
||||
@@ -94,6 +102,7 @@ export const load: PageServerLoad = async ({ params, parent }) => {
|
||||
monitorLastLatency: ParseLatency(item.avgLatency),
|
||||
ongoingIncidents,
|
||||
ongoingMaintenances,
|
||||
upcomingMaintenances,
|
||||
externalUrl: monitor.external_url,
|
||||
extendedTags,
|
||||
monitorGroupMembersByTag,
|
||||
|
||||
@@ -29,9 +29,16 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.monitorName + " - " + data.siteName}</title>
|
||||
<!-- meta description -->
|
||||
<meta property="og:title" content={data.monitorName + " - " + data.siteName} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{#if data.monitorDescription}
|
||||
<meta name="description" content={data.monitorDescription} />
|
||||
<meta property="og:description" content={data.monitorDescription} />
|
||||
{/if}
|
||||
{#if data.socialPreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
<div class="flex flex-col gap-3">
|
||||
@@ -47,10 +54,12 @@
|
||||
<Item.Root class="px-0 py-0 ">
|
||||
<Item.Content>
|
||||
{#if data.monitorName}
|
||||
<Item.Title class="text-3xl">{data.monitorName}</Item.Title>
|
||||
<h1>
|
||||
<Item.Title class="text-3xl">{data.monitorName}</Item.Title>
|
||||
</h1>
|
||||
{/if}
|
||||
{#if data.monitorDescription}
|
||||
<div class="">
|
||||
<h2 class="">
|
||||
<Item.Description
|
||||
class="text-muted-foreground w-full {descriptionExpanded ? 'line-clamp-none' : ''} text-pretty"
|
||||
>
|
||||
@@ -74,7 +83,7 @@
|
||||
{data.monitorDescription}
|
||||
{/if}
|
||||
</Item.Description>
|
||||
</div>
|
||||
</h2>
|
||||
{/if}
|
||||
</Item.Content>
|
||||
</Item.Root>
|
||||
@@ -134,6 +143,15 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.upcomingMaintenances && data.upcomingMaintenances.length > 0}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each data.upcomingMaintenances as maintenance, i (maintenance.id ?? i)}
|
||||
<div class="rounded-3xl border p-3 sm:p-4">
|
||||
<MaintenanceItem {maintenance} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Calendar View (self-contained component with its own API call) -->
|
||||
<MonitorOverview
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { GetSiteDataByKey } from "$lib/server/controllers/siteDataController.js";
|
||||
import { GetAllPages } from "$lib/server/controllers/pagesController.js";
|
||||
import { GetMonitors } from "$lib/server/controllers/monitorsController.js";
|
||||
import serverResolver from "$lib/server/resolver.js";
|
||||
import type { SitemapXMLConfig } from "$lib/types/site.js";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const sitemap = (await GetSiteDataByKey("sitemap")) as SitemapXMLConfig | null;
|
||||
const mode = sitemap?.mode ?? "auto";
|
||||
|
||||
if (mode === "off") {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
if (mode === "manual") {
|
||||
const urls = sitemap?.urls ?? [];
|
||||
const urlEntries = urls
|
||||
.filter((u) => u.loc.trim().length > 0)
|
||||
.map((u) => ` <url>\n <loc>${escapeXml(u.loc.trim())}</loc>\n </url>`)
|
||||
.join("\n");
|
||||
|
||||
return sitemapResponse(urlEntries);
|
||||
}
|
||||
|
||||
// auto mode
|
||||
const siteURL = (await GetSiteDataByKey("siteURL")) as string | null;
|
||||
if (!siteURL) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const locs: string[] = [];
|
||||
|
||||
// Add pages
|
||||
const pages = await GetAllPages();
|
||||
for (const page of pages) {
|
||||
const path = page.page_path ? `/${page.page_path}` : "/";
|
||||
locs.push(siteURL + serverResolver(path));
|
||||
}
|
||||
|
||||
// Add active, visible monitors
|
||||
const monitors = await GetMonitors({ status: "ACTIVE", is_hidden: "NO" });
|
||||
for (const monitor of monitors) {
|
||||
locs.push(siteURL + serverResolver(`/monitors/${monitor.tag}`));
|
||||
}
|
||||
|
||||
// Add current and previous month events pages
|
||||
const now = new Date();
|
||||
const months = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
const currentMonth = `${months[now.getUTCMonth()]}-${now.getUTCFullYear()}`;
|
||||
const prevDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
|
||||
const previousMonth = `${months[prevDate.getUTCMonth()]}-${prevDate.getUTCFullYear()}`;
|
||||
locs.push(siteURL + serverResolver(`/events/${currentMonth}`));
|
||||
locs.push(siteURL + serverResolver(`/events/${previousMonth}`));
|
||||
|
||||
// Add any manual URLs configured alongside auto
|
||||
const manualUrls = sitemap?.urls ?? [];
|
||||
for (const u of manualUrls) {
|
||||
if (u.loc.trim().length > 0) {
|
||||
locs.push(u.loc.trim());
|
||||
}
|
||||
}
|
||||
|
||||
const urlEntries = locs.map((loc) => ` <url>\n <loc>${escapeXml(loc)}</loc>\n </url>`).join("\n");
|
||||
|
||||
return sitemapResponse(urlEntries);
|
||||
};
|
||||
|
||||
function sitemapResponse(urlEntries: string): Response {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urlEntries}
|
||||
</urlset>`;
|
||||
|
||||
return new Response(xml, {
|
||||
headers: {
|
||||
"Content-Type": "application/xml",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { redirect } from "@sveltejs/kit";
|
||||
import MobileDetect from "mobile-detect";
|
||||
import type { LayoutServerLoad } from "./$types";
|
||||
import { IsEmailSetup } from "$lib/server/controllers/controller.js";
|
||||
import GC from "$lib/global-constants";
|
||||
import seedSiteData from "$lib/server/db/seedSiteData.js";
|
||||
import serverResolve from "$lib/server/resolver.js";
|
||||
|
||||
import { resolve } from "$app/paths";
|
||||
@@ -38,5 +38,6 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
|
||||
siteStatusColorsDark,
|
||||
font,
|
||||
canSendEmail: IsEmailSetup(),
|
||||
seedSiteData,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
<Toaster />
|
||||
|
||||
<svelte:head>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>{pageTitle} | Kener</title>
|
||||
<link rel="icon" href={clientResolver(resolve, "/logo96.png")} />
|
||||
{#if data.font?.cssSrc}
|
||||
|
||||
@@ -118,6 +118,7 @@ import sendWebhook from "$lib/server/notification/webhook_notification.js";
|
||||
import sendEmail from "$lib/server/notification/email_notification.js";
|
||||
import sendDiscord from "$lib/server/notification/discord_notification.js";
|
||||
import sendSlack from "$lib/server/notification/slack_notification.js";
|
||||
import heicConvert from "heic-convert";
|
||||
import serverResolver from "$lib/server/resolver.js";
|
||||
|
||||
function AdminCan(role: string) {
|
||||
@@ -324,6 +325,7 @@ export async function POST({ request, cookies }) {
|
||||
const testAlertData: MonitorAlertV2Record = {
|
||||
id: 1,
|
||||
config_id: 1,
|
||||
monitor_tag: null,
|
||||
incident_id: 4,
|
||||
alert_status: Math.random() > 0.5 ? "TRIGGERED" : "RESOLVED",
|
||||
created_at: new Date(),
|
||||
@@ -716,7 +718,7 @@ async function uploadImage(data: ImageUploadData): Promise<{ id: string; url: st
|
||||
throw new Error("Image data is required");
|
||||
}
|
||||
|
||||
const allowedMimeTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
|
||||
const allowedMimeTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp", "image/heic", "image/heif"];
|
||||
if (!allowedMimeTypes.includes(mimeType)) {
|
||||
throw new Error(`Invalid image type. Allowed types: ${allowedMimeTypes.join(", ")}`);
|
||||
}
|
||||
@@ -744,8 +746,21 @@ async function uploadImage(data: ImageUploadData): Promise<{ id: string; url: st
|
||||
let width: number | undefined;
|
||||
let height: number | undefined;
|
||||
|
||||
// Pre-convert HEIC/HEIF to JPEG before passing to sharp (sharp may lack HEVC codec)
|
||||
let sharpInputBuffer = imageBuffer;
|
||||
const heicSignature = imageBuffer.subarray(4, 12).toString("ascii");
|
||||
const isHeicData = heicSignature.includes("ftyp");
|
||||
if (isHeicData) {
|
||||
const converted = await heicConvert({
|
||||
buffer: new Uint8Array(imageBuffer) as unknown as ArrayBuffer,
|
||||
format: "JPEG",
|
||||
quality: 0.85,
|
||||
});
|
||||
sharpInputBuffer = Buffer.from(converted);
|
||||
}
|
||||
|
||||
// Process with sharp and normalize output
|
||||
const image = sharp(imageBuffer, { limitInputPixels: GC.MAX_INPUT_PIXELS });
|
||||
const image = sharp(sharpInputBuffer, { limitInputPixels: GC.MAX_INPUT_PIXELS });
|
||||
const metadata = await image.metadata();
|
||||
|
||||
const formatToMime: Record<string, string> = {
|
||||
@@ -753,6 +768,8 @@ async function uploadImage(data: ImageUploadData): Promise<{ id: string; url: st
|
||||
jpeg: "image/jpeg",
|
||||
webp: "image/webp",
|
||||
svg: "image/svg+xml",
|
||||
heic: "image/heic",
|
||||
heif: "image/heif",
|
||||
};
|
||||
|
||||
const detectedMimeType = metadata.format ? formatToMime[metadata.format] : undefined;
|
||||
@@ -764,7 +781,10 @@ async function uploadImage(data: ImageUploadData): Promise<{ id: string; url: st
|
||||
throw new Error("SVG uploads are not allowed");
|
||||
}
|
||||
|
||||
if (normalizedRequestedMime !== detectedMimeType) {
|
||||
// HEIC/HEIF files often have .jpg extension (e.g. iPhone photos); allow the mismatch
|
||||
const isHeicDetected = detectedMimeType === "image/heic" || detectedMimeType === "image/heif";
|
||||
const isHeicRequested = normalizedRequestedMime === "image/heic" || normalizedRequestedMime === "image/heif";
|
||||
if (normalizedRequestedMime !== detectedMimeType && !isHeicDetected && !isHeicRequested) {
|
||||
throw new Error("Image MIME type does not match file content");
|
||||
}
|
||||
|
||||
@@ -796,8 +816,8 @@ async function uploadImage(data: ImageUploadData): Promise<{ id: string; url: st
|
||||
width = newWidth;
|
||||
height = newHeight;
|
||||
|
||||
// Keep JPEG as JPEG; convert everything else (WebP/PNG) to PNG.
|
||||
if (detectedMimeType === "image/jpeg") {
|
||||
// Keep JPEG as JPEG; convert HEIC/HEIF to JPEG; convert everything else (WebP/PNG) to PNG.
|
||||
if (detectedMimeType === "image/jpeg" || isHeicDetected) {
|
||||
processedBuffer = await image
|
||||
.resize(newWidth, newHeight, {
|
||||
fit: forceDimensions ? "cover" : "inside",
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Monitor</Table.Head>
|
||||
<Table.Head>Monitors</Table.Head>
|
||||
<Table.Head>Alert Type</Table.Head>
|
||||
<Table.Head>Severity</Table.Head>
|
||||
<Table.Head>Description</Table.Head>
|
||||
@@ -198,12 +198,23 @@
|
||||
{#each configs as config (config.id)}
|
||||
<Table.Row class={config.is_active === GC.NO ? "opacity-60" : ""}>
|
||||
<Table.Cell>
|
||||
<a
|
||||
href={clientResolver(resolve, `/manage/app/monitors/${config.monitor_tag}`)}
|
||||
class="text-primary font-medium hover:underline"
|
||||
>
|
||||
{config.monitor_tag}
|
||||
</a>
|
||||
{#if config.monitor_tags && config.monitor_tags.length > 0}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each config.monitor_tags as tag}
|
||||
<a
|
||||
href={clientResolver(resolve, `/manage/app/monitors/${tag}`)}
|
||||
class="text-primary text-sm font-medium hover:underline"
|
||||
>
|
||||
{monitors.find((m) => m.tag === tag)?.name || tag}
|
||||
</a>
|
||||
{#if config.monitor_tags.indexOf(tag) < config.monitor_tags.length - 1}
|
||||
<span class="text-muted-foreground">,</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-sm">-</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline">{config.alert_for}</Badge>
|
||||
|
||||
@@ -15,8 +15,13 @@
|
||||
import * as Select from "$lib/components/ui/select/index.js";
|
||||
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
|
||||
import * as Breadcrumb from "$lib/components/ui/breadcrumb/index.js";
|
||||
import * as Popover from "$lib/components/ui/popover/index.js";
|
||||
import * as Command from "$lib/components/ui/command/index.js";
|
||||
import ArrowLeftIcon from "@lucide/svelte/icons/arrow-left";
|
||||
import TrashIcon from "@lucide/svelte/icons/trash";
|
||||
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
|
||||
import CheckIcon from "@lucide/svelte/icons/check";
|
||||
import XIcon from "@lucide/svelte/icons/x";
|
||||
import GC from "$lib/global-constants";
|
||||
import type {
|
||||
TriggerRecord,
|
||||
@@ -39,10 +44,11 @@
|
||||
let triggers = $state<TriggerRecord[]>([]);
|
||||
let monitors = $state<{ tag: string; name: string }[]>([]);
|
||||
let deleteDialogOpen = $state(false);
|
||||
let monitorPopoverOpen = $state(false);
|
||||
|
||||
// Form state
|
||||
const defaultForm = {
|
||||
monitor_tag: "",
|
||||
monitor_tags: [] as string[],
|
||||
alert_for: "STATUS" as AlertForType,
|
||||
alert_value: "DOWN",
|
||||
failure_threshold: 3,
|
||||
@@ -103,6 +109,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMonitor(monitorTag: string) {
|
||||
if (form.monitor_tags.includes(monitorTag)) {
|
||||
form.monitor_tags = form.monitor_tags.filter((tag) => tag !== monitorTag);
|
||||
} else {
|
||||
form.monitor_tags = [...form.monitor_tags, monitorTag];
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTrigger(triggerId: number) {
|
||||
if (form.trigger_ids.includes(triggerId)) {
|
||||
form.trigger_ids = form.trigger_ids.filter((id) => id !== triggerId);
|
||||
@@ -166,7 +180,7 @@
|
||||
} else {
|
||||
const config = result as MonitorAlertConfigWithTriggers;
|
||||
form = {
|
||||
monitor_tag: config.monitor_tag,
|
||||
monitor_tags: config.monitor_tags || [],
|
||||
alert_for: config.alert_for,
|
||||
alert_value: config.alert_value,
|
||||
failure_threshold: config.failure_threshold,
|
||||
@@ -186,8 +200,8 @@
|
||||
}
|
||||
|
||||
async function saveAlertConfig() {
|
||||
if (!form.monitor_tag) {
|
||||
toast.error("Please select a monitor");
|
||||
if (form.monitor_tags.length === 0) {
|
||||
toast.error("Please select at least one monitor");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -195,7 +209,7 @@
|
||||
try {
|
||||
const action = isNew ? "createMonitorAlertConfig" : "updateMonitorAlertConfig";
|
||||
const data: Record<string, unknown> = {
|
||||
monitor_tag: form.monitor_tag,
|
||||
monitor_tags: form.monitor_tags,
|
||||
alert_for: form.alert_for,
|
||||
alert_value: form.alert_value,
|
||||
failure_threshold: form.failure_threshold,
|
||||
@@ -287,28 +301,58 @@
|
||||
{:else}
|
||||
<Card.Root>
|
||||
<Card.Content class="space-y-6 pt-6">
|
||||
<!-- Monitor Selection -->
|
||||
<!-- Monitor Selection (Searchable Multi-select) -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="monitor">Monitor</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={form.monitor_tag}
|
||||
onValueChange={(v) => v && (form.monitor_tag = v)}
|
||||
disabled={!isNew}
|
||||
>
|
||||
<Select.Trigger id="monitor" class="w-full">
|
||||
{form.monitor_tag
|
||||
? monitors.find((m) => m.tag === form.monitor_tag)?.name || form.monitor_tag
|
||||
: "Select monitor"}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each monitors as monitor}
|
||||
<Select.Item value={monitor.tag}>{monitor.name}</Select.Item>
|
||||
<Label>Monitors</Label>
|
||||
<p class="text-muted-foreground text-xs">Select which monitors this alert applies to</p>
|
||||
<Popover.Root bind:open={monitorPopoverOpen}>
|
||||
<Popover.Trigger>
|
||||
<Button variant="outline" role="combobox" class="w-full justify-between font-normal">
|
||||
{#if form.monitor_tags.length === 0}
|
||||
Select monitors...
|
||||
{:else if form.monitor_tags.length === 1}
|
||||
{monitors.find((m) => m.tag === form.monitor_tags[0])?.name || form.monitor_tags[0]}
|
||||
{:else}
|
||||
{form.monitor_tags.length} monitors selected
|
||||
{/if}
|
||||
<ChevronsUpDownIcon class="text-muted-foreground size-4 shrink-0" />
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-[var(--bits-popover-trigger-width)] p-0" align="start">
|
||||
<Command.Root>
|
||||
<Command.Input placeholder="Search monitors..." />
|
||||
<Command.List>
|
||||
<Command.Empty>No monitors found.</Command.Empty>
|
||||
<Command.Group>
|
||||
{#each monitors as monitor (monitor.tag)}
|
||||
<Command.Item
|
||||
value={monitor.name}
|
||||
onSelect={() => toggleMonitor(monitor.tag)}
|
||||
>
|
||||
<CheckIcon class="size-4 {form.monitor_tags.includes(monitor.tag) ? 'opacity-100' : 'opacity-0'}" />
|
||||
{monitor.name}
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.Group>
|
||||
</Command.List>
|
||||
</Command.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
{#if form.monitor_tags.length > 0}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each form.monitor_tags as tag (tag)}
|
||||
<Badge variant="secondary" class="gap-1 pr-1">
|
||||
{monitors.find((m) => m.tag === tag)?.name || tag}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-muted rounded-sm p-0.5"
|
||||
onclick={() => toggleMonitor(tag)}
|
||||
>
|
||||
<XIcon class="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{#if !isNew}
|
||||
<p class="text-muted-foreground text-xs">Monitor cannot be changed after creation</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user