mirror of
https://github.com/rajnandan1/kener.git
synced 2026-06-23 04:10:22 +00:00
Compare commits
216 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6764b470a9 | |||
| 9fc16187ca | |||
| 32c36bfc52 | |||
| 9ad4ca50fb | |||
| 2aa83b01c2 | |||
| a25d823db8 | |||
| c43b4ef863 | |||
| a9e06ae9f7 | |||
| a925791671 | |||
| ba1d0079de | |||
| 3503e73f56 | |||
| 5021e01018 | |||
| 95c341fc35 | |||
| e69fcdfd71 | |||
| acf11459ed | |||
| 9ad315c3c7 | |||
| caf7427b04 | |||
| 8833b7e410 | |||
| e43186d121 | |||
| 4536bceef6 | |||
| 734b062626 | |||
| f257cdc2c4 | |||
| 1362d06b20 | |||
| 663ce52c4e | |||
| 3f4df5faa7 | |||
| 2501875406 | |||
| 10586108c5 | |||
| 87c69201ab | |||
| a3ec81af20 | |||
| 61acf53c10 | |||
| 8bbafe4c8a | |||
| 4589568405 | |||
| e8fb4126a8 | |||
| e9d3281067 | |||
| 7439632eff | |||
| f0362fd919 | |||
| e61873164b | |||
| 6d7b56a0ac | |||
| 0940c8d01e | |||
| 17e3fa6d77 | |||
| a363079695 | |||
| 87fc3081df | |||
| 350e291db0 | |||
| 9a545dbf48 | |||
| ab527ff7d8 | |||
| 850ebae11a | |||
| a6948f087c | |||
| aaa7c2a46d | |||
| 8edf92ea02 | |||
| e5e7e44471 | |||
| f5ab338e2b | |||
| 5012ff1421 | |||
| 122ca71b8e | |||
| e27ab6ff7d | |||
| c301aaab90 | |||
| 4ed40a0b08 | |||
| 1ac0f2259f | |||
| 544bdc9dcb | |||
| a843ac2926 | |||
| ccceeb38bd | |||
| b01560c29b | |||
| 80c5e298d7 | |||
| 3d1335bf40 | |||
| 25c893ad86 | |||
| a9f0437d17 | |||
| 604210568b | |||
| 41f5296227 | |||
| 8c1a97d844 | |||
| ed1a70d75b | |||
| e63a2f6311 | |||
| 951ab06f7e | |||
| af4684a90f | |||
| da6eaee3ab | |||
| f264115ab8 | |||
| 15b78dab66 | |||
| 54277ece9a | |||
| 54f056ad34 | |||
| 8d2808c291 | |||
| bd638ccf24 | |||
| c2945485e2 | |||
| a57c92fc0e | |||
| 8e7bc47b14 | |||
| cd26c46493 | |||
| 508b08f8f3 | |||
| 638393efac | |||
| 5a54d69d87 | |||
| 7e5ea5fda1 | |||
| 175cf605c6 | |||
| 6a9bfffbd4 | |||
| 7b120911b4 | |||
| 35817bc20a | |||
| a8fbac1b69 | |||
| cfc99e2f14 | |||
| 31ba10f434 | |||
| 1750e2a341 | |||
| a12df92b94 | |||
| 5d86084138 | |||
| 6e585f4608 | |||
| f4d01c7c37 | |||
| 546118a725 | |||
| e53a577174 | |||
| 45c0e9a1e6 | |||
| 7050f780a3 | |||
| cb93089dcc | |||
| fcd05e1d68 | |||
| db9d7807e0 | |||
| b7e0756c54 | |||
| b920d2f9bc | |||
| 560c87219b | |||
| 94e24eec04 | |||
| 15680a58aa | |||
| 59f0eaef27 | |||
| 8362a73058 | |||
| 52f8c50f50 | |||
| 60868d55ca | |||
| f7e657ee95 | |||
| 63e5ec2886 | |||
| 2aef97c1ed | |||
| 51b2da97e0 | |||
| 50bddcd9a3 | |||
| bd36533b05 | |||
| 17500a0b43 | |||
| 0050cd810b | |||
| 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 | |||
| 36f2ae1f69 | |||
| dcd830eb82 | |||
| 80637fd4aa | |||
| a7f0072f32 | |||
| a39e676a23 | |||
| fbc926036e | |||
| 5b508d19bc | |||
| 589281ce13 | |||
| 36aec8c519 | |||
| 978bfb05f2 | |||
| 3ffd0538a1 | |||
| 932a05a9ee | |||
| 15c62fa40f | |||
| a5afd38520 | |||
| 5138f7fb6e | |||
| 1bed0538db | |||
| bd582eaad3 | |||
| 5803ccadca | |||
| 4f16b06a0f | |||
| fad23e2a01 | |||
| 4b84e47d89 | |||
| 9e020c10f4 | |||
| 6fd47963ec | |||
| df9b36b242 | |||
| f9fe74ef94 | |||
| 823ea6eeb7 | |||
| 6c7606d3b0 | |||
| d2e9437cfa | |||
| a549eb5d1b | |||
| 648f8180e7 | |||
| 9afb8947ac | |||
| 0c8338e2a5 | |||
| df94755c6b | |||
| 204419fccb | |||
| a973494bc8 | |||
| ba459e61ad | |||
| f92fc8ff73 | |||
| 91cb4850ca | |||
| fb7939a4dc | |||
| 1f352591a4 | |||
| 0085621900 | |||
| 0eb789d89a | |||
| 7f21b27bb8 | |||
| e4f001acf7 |
@@ -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"
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"frontend-design@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+6
-1
@@ -34,4 +34,9 @@ temp.js
|
||||
.DS_Store
|
||||
knip-output.txt
|
||||
check-output.txt
|
||||
translation-report.json
|
||||
translation-report.json
|
||||
|
||||
# AI workflow docs (not version-controlled)
|
||||
CONTEXT.md
|
||||
docs/adr/
|
||||
docs/superpowers/
|
||||
+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,17 @@ Read `.claude/skills/` for specialized instructions on:
|
||||
- **svelte-code-writer** - Svelte component creation/editing
|
||||
- **documentation-writer** - Editing docs in `src/routes/(docs)/docs/content/`
|
||||
- **tailwindcss** - Tailwind CSS v4 patterns
|
||||
- **code-context** - Architecture documentation in `.codecontext/`
|
||||
|
||||
## Agent skills
|
||||
|
||||
### Issue tracker
|
||||
|
||||
Issues and PRDs are tracked in GitHub Issues for `rajnandan1/kener`. See `docs/agents/issue-tracker.md`.
|
||||
|
||||
### Triage labels
|
||||
|
||||
Triage uses the default mattpocock/skills label vocabulary. See `docs/agents/triage-labels.md`.
|
||||
|
||||
### Domain docs
|
||||
|
||||
This repo uses a single-context domain-doc layout. See `docs/agents/domain.md`.
|
||||
|
||||
@@ -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"
|
||||
@@ -162,6 +163,7 @@ COPY --chown=node:node --from=builder /app/seeds ./seeds
|
||||
COPY --chown=node:node --from=builder /app/src/lib/server/db/seedSiteData.ts ./src/lib/server/db/seedSiteData.ts
|
||||
COPY --chown=node:node --from=builder /app/src/lib/server/db/seedMonitorData.ts ./src/lib/server/db/seedMonitorData.ts
|
||||
COPY --chown=node:node --from=builder /app/src/lib/server/db/seedPagesData.ts ./src/lib/server/db/seedPagesData.ts
|
||||
COPY --chown=node:node --from=builder /app/src/lib/allPerms.ts ./src/lib/allPerms.ts
|
||||
COPY --chown=node:node --from=builder /app/src/lib/server/templates/general ./src/lib/server/templates/general
|
||||
|
||||
# Locale JSON files (read at runtime by server-side i18n)
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<a href="https://github.com/rajnandan1/kener/actions/workflows/publish-images.yml"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/rajnandan1/kener/publish-images.yml" /></a>
|
||||
<a href="https://github.com/rajnandan1/kener/commit/HEAD"><img src="https://img.shields.io/github/last-commit/rajnandan1/kener/main" alt="" /></a>
|
||||
<a href="https://github.com/rajnandan1/kener/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/rajnandan1/kener.svg" /></a>
|
||||
<a href="https://deepwiki.com/rajnandan1/kener"><img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -46,6 +47,14 @@
|
||||
| [🌍 Live Server](https://kener.ing) | [🎉 Quick Start](https://kener.ing/docs/v4/getting-started/quick-start) | [🗄 Documentation](https://kener.ing/docs/v4/getting-started/introduction) |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
|
||||
<p align="center">
|
||||
|
||||
[](https://railway.com/deploy/spSvic?referralCode=1Pn7vs&utm_medium=integration&utm_source=template&utm_campaign=generic)
|
||||
[](https://zeabur.com/templates/1YRTMI?referralCode=rajnandan1)
|
||||
[](https://render.com/deploy?repo=https%3A%2F%2Fgithub.com%2Frajnandan1%2Fkener)
|
||||
|
||||
</p>
|
||||
|
||||
## What is Kener?
|
||||
|
||||
**Kener** is a sleek and lightweight status page system built with **SvelteKit** and **NodeJS**. It’s not here to replace heavyweights like Datadog or Atlassian but rather to offer a simple, modern, and hassle-free way to set up a great-looking status page with minimal effort.
|
||||
@@ -170,10 +179,6 @@ For the full quick start (including local Docker builds and dev mode), see the d
|
||||
|
||||
- https://kener.ing/docs/v4/getting-started/quick-start
|
||||
|
||||
## One Click Deployment
|
||||
|
||||
[](https://railway.com/deploy/spSvic?referralCode=1Pn7vs&utm_medium=integration&utm_source=template&utm_campaign=generic)
|
||||
|
||||
## Features
|
||||
|
||||
Kener combines public status page essentials with advanced admin workflows.
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Domain Docs
|
||||
|
||||
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
|
||||
|
||||
This repo is configured as a **single-context** repo.
|
||||
|
||||
## Before exploring, read these
|
||||
|
||||
- **`CONTEXT.md`** at the repo root.
|
||||
- **`docs/adr/`** — read ADRs that touch the area you're about to work in.
|
||||
|
||||
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved.
|
||||
|
||||
## File structure
|
||||
|
||||
Single-context repo:
|
||||
|
||||
```text
|
||||
/
|
||||
├── CONTEXT.md
|
||||
├── docs/adr/
|
||||
│ ├── 0001-event-sourced-orders.md
|
||||
│ └── 0002-postgres-for-write-model.md
|
||||
└── src/
|
||||
```
|
||||
|
||||
## Use the glossary's vocabulary
|
||||
|
||||
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
|
||||
|
||||
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`).
|
||||
|
||||
## Flag ADR conflicts
|
||||
|
||||
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
|
||||
|
||||
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
|
||||
@@ -0,0 +1,22 @@
|
||||
# Issue tracker: GitHub
|
||||
|
||||
Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
|
||||
- **Read an issue**: `gh issue view <number> --comments`, filtering comments by `jq` and also fetching labels.
|
||||
- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
|
||||
- **Comment on an issue**: `gh issue comment <number> --body "..."`
|
||||
- **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
|
||||
- **Close**: `gh issue close <number> --comment "..."`
|
||||
|
||||
Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone.
|
||||
|
||||
## When a skill says "publish to the issue tracker"
|
||||
|
||||
Create a GitHub issue.
|
||||
|
||||
## When a skill says "fetch the relevant ticket"
|
||||
|
||||
Run `gh issue view <number> --comments`.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Triage Labels
|
||||
|
||||
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
|
||||
|
||||
| Label in mattpocock/skills | Label in our tracker | Meaning |
|
||||
| -------------------------- | -------------------- | ---------------------------------------- |
|
||||
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
|
||||
| `needs-info` | `needs-info` | Waiting on reporter for more information |
|
||||
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
|
||||
| `ready-for-human` | `ready-for-human` | Requires human implementation |
|
||||
| `wontfix` | `wontfix` | Will not be actioned |
|
||||
|
||||
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
|
||||
|
||||
Edit the right-hand column to match whatever vocabulary you actually use.
|
||||
+74
-3
@@ -7,13 +7,63 @@ const databaseURLParts = databaseURL.split("://");
|
||||
const databaseType = databaseURLParts[0];
|
||||
const databasePath = databaseURLParts[1];
|
||||
|
||||
const intFromEnv = (name: string, fallback: number): number => {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined) return fallback;
|
||||
const parsed = parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
};
|
||||
|
||||
// TCP keepalive on pooled connections, on by default. Cloud networks (Railway,
|
||||
// Docker Swarm overlays, k8s) silently drop idle TCP connections; without
|
||||
// keepalive the pool keeps handing out dead sockets after an idle period or a
|
||||
// database restart. See docs .../setup/database-setup.md.
|
||||
const keepAliveEnabled = process.env.DATABASE_KEEPALIVE !== "false";
|
||||
|
||||
interface PoolConfig {
|
||||
min: number;
|
||||
max: number;
|
||||
idleTimeoutMillis: number;
|
||||
createTimeoutMillis: number;
|
||||
}
|
||||
|
||||
// Two pools share one process (Postgres/MySQL only): the WEB pool serves
|
||||
// SvelteKit requests; the WORKER pool serves background jobs (BullMQ workers +
|
||||
// schedulers, routed via src/lib/server/db/poolContext.ts). Isolating them
|
||||
// stops a burst of background jobs from exhausting the connections that serve
|
||||
// page loads. Budget across both pools: replicas * (web + worker) must stay
|
||||
// under the database's max_connections. SQLite has no real pool and reuses a
|
||||
// single connection, so the split does not apply there.
|
||||
//
|
||||
// Pool defaults deviate from knex's on purpose:
|
||||
// - min 0: knex's min 2 connections are never reaped, so they are exactly the
|
||||
// ones that go stale and wedge the app until a manual restart
|
||||
// - 15s acquire/create timeouts: fail fast instead of hanging requests for
|
||||
// knex's default 60s during a database outage
|
||||
// Tarn requires max >= 1 and min <= max; clamp so a bad env value can not
|
||||
// produce a pool that fails every acquire
|
||||
const idleTimeoutMillis = intFromEnv("DATABASE_IDLE_TIMEOUT_MS", 30000);
|
||||
const createTimeoutMillis = intFromEnv("DATABASE_CREATE_TIMEOUT_MS", 15000);
|
||||
const poolMin = intFromEnv("DATABASE_POOL_MIN", 0);
|
||||
const buildPool = (max: number): PoolConfig => ({
|
||||
min: Math.min(poolMin, max),
|
||||
max,
|
||||
idleTimeoutMillis,
|
||||
createTimeoutMillis,
|
||||
});
|
||||
const webPool = buildPool(Math.max(1, intFromEnv("DATABASE_POOL_MAX", 10)));
|
||||
const workerPool = buildPool(Math.max(1, intFromEnv("DATABASE_WORKER_POOL_MAX", 5)));
|
||||
const acquireConnectionTimeout = intFromEnv("DATABASE_ACQUIRE_TIMEOUT_MS", 15000);
|
||||
|
||||
interface KnexConfig {
|
||||
migrations: { directory: string };
|
||||
seeds: { directory: string };
|
||||
databaseType: string;
|
||||
client?: string;
|
||||
connection?: string | { filename: string };
|
||||
connection?: string | { filename: string } | Record<string, unknown>;
|
||||
useNullAsDefault?: boolean;
|
||||
pool?: PoolConfig;
|
||||
acquireConnectionTimeout?: number;
|
||||
}
|
||||
|
||||
const knexOb: KnexConfig = {
|
||||
@@ -25,6 +75,13 @@ const knexOb: KnexConfig = {
|
||||
},
|
||||
databaseType,
|
||||
};
|
||||
|
||||
// Worker pool config for Postgres/MySQL — same connection as the web config,
|
||||
// but with the worker pool. Stays null for SQLite (single shared connection),
|
||||
// in which case the app reuses the web instance for background work too.
|
||||
let workerKnexOb: KnexConfig | null = null;
|
||||
|
||||
console.log(`Configuring database with type ${databaseType}`);
|
||||
if (databaseType === "sqlite") {
|
||||
knexOb.client = "better-sqlite3";
|
||||
knexOb.connection = {
|
||||
@@ -33,13 +90,27 @@ if (databaseType === "sqlite") {
|
||||
knexOb.useNullAsDefault = true;
|
||||
} else if (databaseType === "postgresql") {
|
||||
knexOb.client = "pg";
|
||||
knexOb.connection = databaseURL;
|
||||
knexOb.connection = {
|
||||
connectionString: databaseURL,
|
||||
keepAlive: keepAliveEnabled,
|
||||
};
|
||||
knexOb.pool = webPool;
|
||||
knexOb.acquireConnectionTimeout = acquireConnectionTimeout;
|
||||
workerKnexOb = { ...knexOb, pool: workerPool };
|
||||
} else if (databaseType === "mysql") {
|
||||
knexOb.client = "mysql2";
|
||||
knexOb.connection = databaseURL;
|
||||
knexOb.connection = {
|
||||
uri: databaseURL,
|
||||
enableKeepAlive: keepAliveEnabled,
|
||||
keepAliveInitialDelay: 10000,
|
||||
};
|
||||
knexOb.pool = webPool;
|
||||
knexOb.acquireConnectionTimeout = acquireConnectionTimeout;
|
||||
workerKnexOb = { ...knexOb, pool: workerPool };
|
||||
} else {
|
||||
console.error("Invalid database type");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export { workerKnexOb };
|
||||
export default knexOb;
|
||||
|
||||
@@ -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,19 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasColumn = await knex.schema.hasColumn("pages_monitors", "position");
|
||||
if (!hasColumn) {
|
||||
await knex.schema.alterTable("pages_monitors", (table) => {
|
||||
table.integer("position").unsigned().notNullable().defaultTo(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasColumn = await knex.schema.hasColumn("pages_monitors", "position");
|
||||
if (hasColumn) {
|
||||
await knex.schema.alterTable("pages_monitors", (table) => {
|
||||
table.dropColumn("position");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// 1. Roles table
|
||||
if (!(await knex.schema.hasTable("roles"))) {
|
||||
await knex.schema.createTable("roles", (table) => {
|
||||
table.string("id", 100).primary();
|
||||
table.text("role_name").notNullable();
|
||||
table.integer("readonly").notNullable().defaultTo(0);
|
||||
table.string("status", 20).notNullable().defaultTo("ACTIVE");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Permissions table
|
||||
if (!(await knex.schema.hasTable("permissions"))) {
|
||||
await knex.schema.createTable("permissions", (table) => {
|
||||
table.string("id", 100).primary();
|
||||
table.text("permission_name").notNullable();
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Roles ↔ Permissions junction table
|
||||
if (!(await knex.schema.hasTable("roles_permissions"))) {
|
||||
await knex.schema.createTable("roles_permissions", (table) => {
|
||||
table.string("roles_id", 100).notNullable().references("id").inTable("roles").onDelete("CASCADE");
|
||||
table.string("permissions_id", 100).notNullable().references("id").inTable("permissions").onDelete("CASCADE");
|
||||
table.string("status", 20).notNullable().defaultTo("ACTIVE");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
table.primary(["roles_id", "permissions_id"]);
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Users ↔ Roles junction table
|
||||
if (!(await knex.schema.hasTable("users_roles"))) {
|
||||
await knex.schema.createTable("users_roles", (table) => {
|
||||
table.string("roles_id", 100).notNullable().references("id").inTable("roles").onDelete("CASCADE");
|
||||
table.integer("users_id").unsigned().notNullable().references("id").inTable("users").onDelete("CASCADE");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
table.primary(["roles_id", "users_id"]);
|
||||
table.index("users_id", "idx_users_roles_users_id");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists("users_roles");
|
||||
await knex.schema.dropTableIfExists("roles_permissions");
|
||||
await knex.schema.dropTableIfExists("permissions");
|
||||
await knex.schema.dropTableIfExists("roles");
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
// Maps the legacy users.role string to the new roles.id value.
|
||||
// The old default was "user"; everything unmapped falls back to "member".
|
||||
const ROLE_MAP: Record<string, string> = {
|
||||
admin: "admin",
|
||||
editor: "editor",
|
||||
member: "member",
|
||||
user: "member",
|
||||
};
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasColumn = await knex.schema.hasColumn("users", "role");
|
||||
if (!hasColumn) return;
|
||||
|
||||
// 1. Ensure the three target roles exist so FK inserts succeed.
|
||||
// Seeds will reconcile permissions later; we only need the rows.
|
||||
const rolesToEnsure = [
|
||||
{ id: "admin", role_name: "Administrator" },
|
||||
{ id: "editor", role_name: "Editor" },
|
||||
{ id: "member", role_name: "Member" },
|
||||
];
|
||||
for (const role of rolesToEnsure) {
|
||||
const exists = await knex("roles").where("id", role.id).first();
|
||||
if (!exists) {
|
||||
await knex("roles").insert({
|
||||
id: role.id,
|
||||
role_name: role.role_name,
|
||||
readonly: 1,
|
||||
status: "ACTIVE",
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Read users.role into memory BEFORE dropping the column.
|
||||
// On SQLite, dropColumn recreates the table (create → copy → drop → rename),
|
||||
// which can discard DML inserts to tables with FKs pointing at users.
|
||||
const users: Array<{ id: number; role: string }> = await knex("users").select("id", "role");
|
||||
|
||||
// 3. Drop the column first.
|
||||
await knex.schema.alterTable("users", (table) => {
|
||||
table.dropColumn("role");
|
||||
});
|
||||
|
||||
// 4. Now populate users_roles from the in-memory snapshot.
|
||||
for (const user of users) {
|
||||
const newRoleId = ROLE_MAP[user.role] ?? "member";
|
||||
|
||||
const alreadyAssigned = await knex("users_roles").where({ roles_id: newRoleId, users_id: user.id }).first();
|
||||
|
||||
if (!alreadyAssigned) {
|
||||
await knex("users_roles").insert({
|
||||
roles_id: newRoleId,
|
||||
users_id: user.id,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse map: pick the highest-precedence role when backfilling.
|
||||
const REVERSE_ROLE_PRECEDENCE: string[] = ["admin", "editor", "member"];
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasColumn = await knex.schema.hasColumn("users", "role");
|
||||
if (!hasColumn) {
|
||||
await knex.schema.alterTable("users", (table) => {
|
||||
table.string("role").defaultTo("member");
|
||||
});
|
||||
}
|
||||
|
||||
// Backfill users.role from users_roles using deterministic precedence
|
||||
const assignments: Array<{ users_id: number; roles_id: string }> = await knex("users_roles").select(
|
||||
"users_id",
|
||||
"roles_id",
|
||||
);
|
||||
|
||||
// Group roles by user
|
||||
const userRolesMap = new Map<number, string[]>();
|
||||
for (const row of assignments) {
|
||||
const list = userRolesMap.get(row.users_id) || [];
|
||||
list.push(row.roles_id);
|
||||
userRolesMap.set(row.users_id, list);
|
||||
}
|
||||
|
||||
// Pick highest-precedence role for each user
|
||||
for (const [userId, roleIds] of userRolesMap) {
|
||||
const bestRole = REVERSE_ROLE_PRECEDENCE.find((r) => roleIds.includes(r)) || roleIds[0] || "member";
|
||||
await knex("users").where("id", userId).update({ role: bestRole });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasColumn("monitors", "confirmation_threshold"))) {
|
||||
await knex.schema.alterTable("monitors", (table) => {
|
||||
table.integer("confirmation_threshold").unsigned().notNullable().defaultTo(1);
|
||||
});
|
||||
}
|
||||
if (!(await knex.schema.hasColumn("monitoring_data", "raw_status"))) {
|
||||
await knex.schema.alterTable("monitoring_data", (table) => {
|
||||
table.text("raw_status").nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasColumn("monitors", "confirmation_threshold")) {
|
||||
await knex.schema.alterTable("monitors", (table) => {
|
||||
table.dropColumn("confirmation_threshold");
|
||||
});
|
||||
}
|
||||
if (await knex.schema.hasColumn("monitoring_data", "raw_status")) {
|
||||
await knex.schema.alterTable("monitoring_data", (table) => {
|
||||
table.dropColumn("raw_status");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// Covering index for the grouped status-count aggregation used by the
|
||||
// monitor-bars dashboard endpoint. The query filters by
|
||||
// (monitor_tag, timestamp range) and reads status + latency for every row.
|
||||
// Including status and latency in the index lets the database satisfy the
|
||||
// query from the index alone, avoiding a heap lookup per matched row.
|
||||
try {
|
||||
await knex.schema.alterTable("monitoring_data", (table) => {
|
||||
table.index(
|
||||
["monitor_tag", "timestamp", "status", "latency"],
|
||||
"idx_monitoring_data_monitor_tag_timestamp_status_latency",
|
||||
);
|
||||
});
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable("monitoring_data", (table) => {
|
||||
table.dropIndex(
|
||||
["monitor_tag", "timestamp", "status", "latency"],
|
||||
"idx_monitoring_data_monitor_tag_timestamp_status_latency",
|
||||
);
|
||||
});
|
||||
}
|
||||
Generated
+407
-237
File diff suppressed because it is too large
Load Diff
+17
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kener",
|
||||
"version": "4.0.15",
|
||||
"version": "4.1.1",
|
||||
"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",
|
||||
@@ -112,6 +113,8 @@
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.39.11",
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@humanspeak/svelte-purify": "^0.0.6",
|
||||
"@number-flow/svelte": "^0.3.9",
|
||||
"@scalar/express-api-reference": "^0.8.28",
|
||||
@@ -137,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",
|
||||
@@ -168,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"
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
services:
|
||||
- type: web
|
||||
name: kener
|
||||
runtime: image
|
||||
image:
|
||||
url: docker.io/rajnandan1/kener:latest
|
||||
envVars:
|
||||
- key: DATABASE_URL
|
||||
fromDatabase:
|
||||
name: kener-db
|
||||
property: connectionString
|
||||
- key: KENER_SECRET_KEY
|
||||
generateValue: true
|
||||
- key: ORIGIN
|
||||
fromService:
|
||||
type: web
|
||||
name: kener
|
||||
envVarKey: RENDER_EXTERNAL_URL
|
||||
- key: REDIS_URL
|
||||
fromService:
|
||||
name: kener-redis
|
||||
type: keyvalue
|
||||
property: connectionString
|
||||
- type: keyvalue
|
||||
name: kener-redis
|
||||
plan: starter
|
||||
ipAllowList:
|
||||
- source: 0.0.0.0/0
|
||||
description: everywhere
|
||||
maxmemoryPolicy: allkeys-lru
|
||||
|
||||
databases:
|
||||
- name: kener-db
|
||||
plan: basic-256mb
|
||||
databaseName: kener
|
||||
@@ -1,247 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { globSync } from "glob";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const localesDir = path.join(projectRoot, "src", "lib", "locales");
|
||||
|
||||
const WHITELISTED_DYNAMIC_KEYS = new Set([
|
||||
"All Systems Operational",
|
||||
"Degraded Performance",
|
||||
"Partial Degraded Performance",
|
||||
"Partial System Outage",
|
||||
"Major System Outage",
|
||||
"No Status Available",
|
||||
]);
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
let reportPath;
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
|
||||
if (arg === "--report") {
|
||||
const next = argv[i + 1];
|
||||
if (!next || next.startsWith("--")) {
|
||||
fail("Missing value for --report. Usage: --report <path>");
|
||||
}
|
||||
reportPath = next;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("--report=")) {
|
||||
reportPath = arg.slice("--report=".length);
|
||||
if (!reportPath) {
|
||||
fail("Missing value for --report. Usage: --report <path>");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
fail(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return { reportPath };
|
||||
}
|
||||
|
||||
function detectReportPath(overridePath) {
|
||||
if (overridePath) {
|
||||
const resolved = path.resolve(projectRoot, overridePath);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
fail(`Report file not found: ${resolved}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const jsonPath = path.join(projectRoot, "translation-report.json");
|
||||
const yamlPath = path.join(projectRoot, "translation-report.yaml");
|
||||
|
||||
if (fs.existsSync(jsonPath)) return jsonPath;
|
||||
if (fs.existsSync(yamlPath)) return yamlPath;
|
||||
|
||||
fail(
|
||||
"Could not find translation report. Expected translation-report.json or translation-report.yaml in project root, or use --report <path>.",
|
||||
);
|
||||
}
|
||||
|
||||
function loadReport(reportPath) {
|
||||
const ext = path.extname(reportPath).toLowerCase();
|
||||
const raw = fs.readFileSync(reportPath, "utf8");
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
if (ext === ".json") {
|
||||
parsed = JSON.parse(raw);
|
||||
} else if (ext === ".yaml" || ext === ".yml") {
|
||||
parsed = yaml.load(raw);
|
||||
} else {
|
||||
fail(`Unsupported report file extension: ${ext}. Use .json, .yaml, or .yml.`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(`Failed to parse report at ${reportPath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
if (!isPlainObject(parsed)) {
|
||||
fail("Invalid report format: expected a top-level object.");
|
||||
}
|
||||
|
||||
if (!isPlainObject(parsed.locales)) {
|
||||
fail("Invalid report format: expected report.locales to be an object.");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getUnusedKeysForLocale(report, localeFileName) {
|
||||
const localeReport = report.locales[localeFileName];
|
||||
|
||||
if (localeReport === undefined) return [];
|
||||
|
||||
if (!isPlainObject(localeReport)) {
|
||||
fail(`Invalid report format for locales.${localeFileName}: expected an object.`);
|
||||
}
|
||||
|
||||
const { unused } = localeReport;
|
||||
|
||||
if (unused === undefined) return [];
|
||||
|
||||
if (!Array.isArray(unused)) {
|
||||
fail(`Invalid report format for locales.${localeFileName}.unused: expected an array.`);
|
||||
}
|
||||
|
||||
const nonStrings = unused.filter((key) => typeof key !== "string");
|
||||
if (nonStrings.length > 0) {
|
||||
fail(`Invalid report format for locales.${localeFileName}.unused: all entries must be strings.`);
|
||||
}
|
||||
|
||||
return unused;
|
||||
}
|
||||
|
||||
function loadLocaleJson(localePath, localeFileName) {
|
||||
const raw = fs.readFileSync(localePath, "utf8");
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
fail(`Invalid JSON in ${localeFileName}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
if (!isPlainObject(parsed)) {
|
||||
fail(`Invalid locale file ${localeFileName}: expected a top-level object.`);
|
||||
}
|
||||
|
||||
if (!isPlainObject(parsed.mappings)) {
|
||||
fail(`Invalid locale file ${localeFileName}: expected \"mappings\" to be an object.`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function sortObjectKeysAscending(input) {
|
||||
const keys = Object.keys(input).sort((a, b) => a.localeCompare(b));
|
||||
const result = {};
|
||||
for (const key of keys) {
|
||||
result[key] = input[key];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function replaceMappingsPreserveTopLevelOrder(localeData, sortedMappings) {
|
||||
const next = {};
|
||||
let sawMappings = false;
|
||||
|
||||
for (const key of Object.keys(localeData)) {
|
||||
if (key === "mappings") {
|
||||
next[key] = sortedMappings;
|
||||
sawMappings = true;
|
||||
} else {
|
||||
next[key] = localeData[key];
|
||||
}
|
||||
}
|
||||
|
||||
if (!sawMappings) {
|
||||
next.mappings = sortedMappings;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function cleanTranslations(report) {
|
||||
if (!fs.existsSync(localesDir)) {
|
||||
fail(`Locales directory not found: ${localesDir}`);
|
||||
}
|
||||
|
||||
const localeFiles = globSync("*.json", {
|
||||
cwd: localesDir,
|
||||
nodir: true,
|
||||
}).sort((a, b) => a.localeCompare(b));
|
||||
|
||||
if (localeFiles.length === 0) {
|
||||
fail(`No locale files found in ${localesDir}`);
|
||||
}
|
||||
|
||||
let totalRemoved = 0;
|
||||
const perFile = [];
|
||||
|
||||
for (const localeFileName of localeFiles) {
|
||||
const localePath = path.join(localesDir, localeFileName);
|
||||
const localeData = loadLocaleJson(localePath, localeFileName);
|
||||
const unusedKeys = getUnusedKeysForLocale(report, localeFileName);
|
||||
const unusedSet = new Set(unusedKeys.filter((key) => !WHITELISTED_DYNAMIC_KEYS.has(key)));
|
||||
|
||||
const currentMappings = localeData.mappings;
|
||||
const cleanedMappings = {};
|
||||
|
||||
let removedCount = 0;
|
||||
for (const [key, value] of Object.entries(currentMappings)) {
|
||||
if (unusedSet.has(key)) {
|
||||
removedCount += 1;
|
||||
} else {
|
||||
cleanedMappings[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const sortedMappings = sortObjectKeysAscending(cleanedMappings);
|
||||
const nextLocaleData = replaceMappingsPreserveTopLevelOrder(localeData, sortedMappings);
|
||||
|
||||
fs.writeFileSync(localePath, `${JSON.stringify(nextLocaleData, null, 2)}\n`, "utf8");
|
||||
|
||||
totalRemoved += removedCount;
|
||||
perFile.push({ file: localeFileName, removed: removedCount });
|
||||
}
|
||||
|
||||
for (const item of perFile) {
|
||||
console.log(`${item.file}: removed ${item.removed} key${item.removed === 1 ? "" : "s"}`);
|
||||
}
|
||||
console.log(`Total removed: ${totalRemoved}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { reportPath: reportArg } = parseArgs(process.argv.slice(2));
|
||||
const reportPath = detectReportPath(reportArg);
|
||||
const report = loadReport(reportPath);
|
||||
|
||||
console.log(`Using report: ${path.relative(projectRoot, reportPath)}`);
|
||||
cleanTranslations(report);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error("Failed to clean translations.");
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
+121
-73
@@ -1,93 +1,141 @@
|
||||
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";
|
||||
import shutdownQueues from "../src/lib/server/queues/shutdown.ts";
|
||||
import dbInstance from "../src/lib/server/db/db.ts";
|
||||
import { redisConnection } from "../src/lib/server/redisConnector.ts";
|
||||
import knex from "knex";
|
||||
import knexOb from "../knexfile.js";
|
||||
|
||||
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);
|
||||
|
||||
//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}`);
|
||||
}
|
||||
// Caps a health probe at 2s so a wedged dependency can not hang the
|
||||
// endpoint. A probe is healthy unless it throws, times out, or resolves false.
|
||||
const probe = async (check: () => Promise<unknown>): Promise<boolean> => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
check(),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("health probe timeout")), 2000);
|
||||
}),
|
||||
]);
|
||||
return result !== false;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
// Reports component health. Always 200 so healthcheck-driven restarters do
|
||||
// not bounce the app while a dependency is down (a restart can not fix a
|
||||
// dead database); pass ?strict=1 to get 503 when any component is down.
|
||||
app.get(base + "/healthcheck", async (req: any, res: any) => {
|
||||
const [dbOk, redisOk] = await Promise.all([
|
||||
probe(() => dbInstance.ping()),
|
||||
// Guard on status before PING: the shared ioredis client has
|
||||
// maxRetriesPerRequest null, so commands sent while disconnected would
|
||||
// queue forever and accumulate across healthcheck polls
|
||||
probe(async () => {
|
||||
const redis = redisConnection();
|
||||
if (redis.status !== "ready") return false;
|
||||
return await redis.ping();
|
||||
}),
|
||||
]);
|
||||
const healthy = dbOk && redisOk;
|
||||
const strict = req.query.strict === "1";
|
||||
res.status(strict && !healthy ? 503 : 200).json({
|
||||
status: healthy ? "ok" : "degraded",
|
||||
db: dbOk,
|
||||
redis: redisOk,
|
||||
});
|
||||
});
|
||||
|
||||
app.use(handler);
|
||||
|
||||
//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();
|
||||
|
||||
@@ -30,6 +30,7 @@ export async function seed(knex: Knex): Promise<void> {
|
||||
page_id: pageId,
|
||||
monitor_tag: "earth",
|
||||
monitor_settings_json: "",
|
||||
position: 0,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
@@ -42,6 +43,7 @@ export async function seed(knex: Knex): Promise<void> {
|
||||
page_id: pageId,
|
||||
monitor_tag: "kener",
|
||||
monitor_settings_json: "",
|
||||
position: 1,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Knex } from "knex";
|
||||
import { permissions } from "../src/lib/allPerms.ts";
|
||||
|
||||
export async function seed(knex: Knex): Promise<void> {
|
||||
const permissionIds = new Set(permissions.map((p) => p.id));
|
||||
|
||||
// Get all existing permissions
|
||||
const existing: Array<{ id: string }> = await knex("permissions").select("id");
|
||||
const existingIds = new Set(existing.map((e) => e.id));
|
||||
|
||||
// Insert missing permissions
|
||||
for (const perm of permissions) {
|
||||
if (!existingIds.has(perm.id)) {
|
||||
await knex("permissions").insert({
|
||||
id: perm.id,
|
||||
permission_name: perm.permission_name,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Delete permissions that are no longer in the seed list
|
||||
const toDelete = existing.filter((e) => !permissionIds.has(e.id)).map((e) => e.id);
|
||||
if (toDelete.length > 0) {
|
||||
await knex("permissions").whereIn("id", toDelete).del();
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import type { Knex } from "knex";
|
||||
import { permissions } from "../src/lib/allPerms.ts";
|
||||
|
||||
/**
|
||||
* Seeds the three readonly roles (admin, editor, member),
|
||||
* assigns permissions to each role in roles_permissions,
|
||||
* and migrates existing users.role → users_roles.
|
||||
*
|
||||
* Permission mapping derived from src/routes/(manage)/manage/api/+server.ts:
|
||||
*
|
||||
* admin → all permissions
|
||||
* editor → all except api_keys.delete (AdminCan-only)
|
||||
* member → all .read permissions only
|
||||
*/
|
||||
|
||||
const readonlyRoles = [
|
||||
{ id: "admin", role_name: "Administrator" },
|
||||
{ id: "editor", role_name: "Editor" },
|
||||
{ id: "member", role_name: "Member" },
|
||||
];
|
||||
|
||||
const allPermissionIds = permissions.map((p) => p.id);
|
||||
const readPermissionIds = allPermissionIds.filter((id) => id.endsWith(".read"));
|
||||
|
||||
const rolePermissions: Record<string, string[]> = {
|
||||
admin: allPermissionIds,
|
||||
editor: allPermissionIds.filter((id) => id !== "api_keys.delete"),
|
||||
member: readPermissionIds,
|
||||
};
|
||||
|
||||
export async function seed(knex: Knex): Promise<void> {
|
||||
// 1. Ensure readonly roles exist
|
||||
for (const role of readonlyRoles) {
|
||||
const existing = await knex("roles").where("id", role.id).first();
|
||||
if (!existing) {
|
||||
await knex("roles").insert({
|
||||
id: role.id,
|
||||
role_name: role.role_name,
|
||||
readonly: 1,
|
||||
status: "ACTIVE",
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Seed roles_permissions for readonly roles
|
||||
// Only insert permissions that actually exist in the permissions table
|
||||
// to avoid FK constraint errors if permissions seed hasn't run yet.
|
||||
const existingPermRows: Array<{ id: string }> = await knex("permissions").select("id");
|
||||
const existingPermIds = new Set(existingPermRows.map((p) => p.id));
|
||||
|
||||
for (const [roleId, permissionIds] of Object.entries(rolePermissions)) {
|
||||
const validPermissionIds = permissionIds.filter((id) => existingPermIds.has(id));
|
||||
|
||||
const existingPerms: Array<{ permissions_id: string }> = await knex("roles_permissions")
|
||||
.where("roles_id", roleId)
|
||||
.select("permissions_id");
|
||||
const existingSet = new Set(existingPerms.map((e) => e.permissions_id));
|
||||
|
||||
// Insert missing permissions
|
||||
for (const permId of validPermissionIds) {
|
||||
if (!existingSet.has(permId)) {
|
||||
await knex("roles_permissions").insert({
|
||||
roles_id: roleId,
|
||||
permissions_id: permId,
|
||||
status: "ACTIVE",
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove permissions no longer assigned to this role
|
||||
const desiredSet = new Set(validPermissionIds);
|
||||
const toRemove = existingPerms.filter((e) => !desiredSet.has(e.permissions_id)).map((e) => e.permissions_id);
|
||||
if (toRemove.length > 0) {
|
||||
await knex("roles_permissions").where("roles_id", roleId).whereIn("permissions_id", toRemove).del();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Migrate existing users: read users.role → insert into users_roles
|
||||
const hasRoleColumn = await knex.schema.hasColumn("users", "role");
|
||||
if (hasRoleColumn) {
|
||||
const users: Array<{ id: number; role: string }> = await knex("users").select("id", "role");
|
||||
|
||||
for (const user of users) {
|
||||
if (!user.role) continue;
|
||||
|
||||
// Only migrate if a matching role exists
|
||||
const roleExists = await knex("roles").where("id", user.role).first();
|
||||
if (!roleExists) continue;
|
||||
|
||||
// Skip if already assigned
|
||||
const existing = await knex("users_roles").where({ roles_id: user.role, users_id: user.id }).first();
|
||||
if (!existing) {
|
||||
await knex("users_roles").insert({
|
||||
roles_id: user.role,
|
||||
users_id: user.id,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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">
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta http-equiv="refresh" content="30" />
|
||||
<title>%sveltekit.status% — Status page temporarily unavailable</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #ffffff;
|
||||
--fg: #09090b;
|
||||
--muted: #71717a;
|
||||
--border: #e4e4e7;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #09090b;
|
||||
--fg: #fafafa;
|
||||
--muted: #a1a1aa;
|
||||
--border: #27272a;
|
||||
}
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
sans-serif;
|
||||
}
|
||||
.card {
|
||||
max-width: 28rem;
|
||||
margin: 1rem;
|
||||
padding: 2rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
p {
|
||||
color: var(--muted);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.code {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>This status page is temporarily unavailable</h1>
|
||||
<p>We are having trouble serving this page right now. It usually resolves on its own.</p>
|
||||
<p>This page will retry automatically in 30 seconds.</p>
|
||||
<div class="code">%sveltekit.status%</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+52
-2
@@ -1,8 +1,10 @@
|
||||
import { json, type Handle } from "@sveltejs/kit";
|
||||
import { sequence } from "@sveltejs/kit/hooks";
|
||||
import { VerifyAPIKey } from "$lib/server/controllers/apiController";
|
||||
import db from "$lib/server/db/db";
|
||||
import type { UnauthorizedResponse, NotFoundResponse } from "$lib/types/api";
|
||||
import { GetMonitorsParsed } from "$lib/server/controllers/monitorsController";
|
||||
import GC from "$lib/global-constants";
|
||||
|
||||
const API_PATH_PREFIX = "/api/";
|
||||
|
||||
@@ -58,7 +60,38 @@ function extractPagePath(pathname: string): string | null {
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Content types that indicate a form submission (mirrors SvelteKit's internal CSRF check scope)
|
||||
const FORM_CONTENT_TYPES = ["application/x-www-form-urlencoded", "multipart/form-data", "text/plain"];
|
||||
|
||||
function isFormContentType(request: Request): boolean {
|
||||
const type = request.headers.get("content-type")?.split(";", 1)[0].trim()?.toLowerCase() ?? "";
|
||||
return FORM_CONTENT_TYPES.includes(type);
|
||||
}
|
||||
|
||||
// Custom CSRF handler: validates Origin when present, allows requests when absent.
|
||||
// When Origin is absent (e.g. Referrer-Policy: no-referrer), security relies on
|
||||
// SameSite=Lax cookies which prevent cross-site POST from carrying auth cookies.
|
||||
const csrfHandle: Handle = async ({ event, resolve }) => {
|
||||
const { request } = event;
|
||||
|
||||
if (
|
||||
isFormContentType(request) &&
|
||||
(request.method === "POST" || request.method === "PUT" || request.method === "PATCH" || request.method === "DELETE")
|
||||
) {
|
||||
const requestOrigin = request.headers.get("origin");
|
||||
if (requestOrigin && requestOrigin !== "null") {
|
||||
const requestHost = new URL(requestOrigin).host;
|
||||
const expectedHost = event.url.host;
|
||||
if (requestHost !== expectedHost) {
|
||||
return new Response(`Cross-site ${request.method} form submissions are forbidden`, { status: 403 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
const apiAuthHandle: Handle = async ({ event, resolve }) => {
|
||||
const { pathname } = event.url;
|
||||
|
||||
// Check if this is an API route that requires authentication
|
||||
@@ -87,6 +120,18 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
return json(errorResponse, { status: 401 });
|
||||
}
|
||||
|
||||
// API consumers must always get JSON; without this, an /api/ path with no
|
||||
// matching route falls through to SvelteKit's HTML error page
|
||||
if (event.route.id === null) {
|
||||
const errorResponse: NotFoundResponse = {
|
||||
error: {
|
||||
code: "NOT_FOUND",
|
||||
message: `No API route matches '${pathname}'`,
|
||||
},
|
||||
};
|
||||
return json(errorResponse, { status: 404 });
|
||||
}
|
||||
|
||||
// Validate monitor tag exists for /api/(vX/)?monitors/:monitor_tag/* routes
|
||||
const monitorTag = extractMonitorTag(pathname);
|
||||
if (monitorTag) {
|
||||
@@ -141,7 +186,10 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Validate page_path exists for /api/(vX/)?pages/:page_path/* routes
|
||||
const pagePath = extractPagePath(pathname);
|
||||
if (pagePath) {
|
||||
const page = await db.getPageByPath(pagePath);
|
||||
// The home page has an empty page_path, unreachable as a URL segment;
|
||||
// the ~home token addresses it instead
|
||||
const lookupPath = pagePath === GC.HOME_PAGE_TOKEN ? "" : pagePath;
|
||||
const page = await db.getPageByPath(lookupPath);
|
||||
if (!page) {
|
||||
const errorResponse: NotFoundResponse = {
|
||||
error: {
|
||||
@@ -160,3 +208,5 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
response.headers.delete("Link");
|
||||
return response;
|
||||
};
|
||||
|
||||
export const handle = sequence(csrfHandle, apiAuthHandle);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Reroute } from "@sveltejs/kit";
|
||||
|
||||
// Back-compat for issue #759: heartbeat URLs used to be `/ext/heartbeat/<tag>:<secret>`,
|
||||
// one path segment joined by a colon. A `:` is illegal in Windows file paths, so the
|
||||
// route is now `/ext/heartbeat/<tag>/<secret>` (two segments). Legacy colon-form URLs
|
||||
// live forever in external cron jobs / uptime pingers, so rewrite them internally to
|
||||
// the new path. Returns a 200 (no redirect) — heartbeat clients often don't follow 3xx.
|
||||
//
|
||||
// `reroute` is a *universal* hook: it MUST be in src/hooks.ts. A `reroute` exported from
|
||||
// src/hooks.server.ts is silently ignored by SvelteKit. Keep this file free of
|
||||
// server-only imports — it is bundled for the client too. Must stay pure/side-effect-free.
|
||||
//
|
||||
// The transform is in-place (no path reconstruction), so any KENER_BASE_PATH prefix is
|
||||
// preserved automatically. `[^/:]+` matches the validated tag charset; only the first
|
||||
// colon after `/ext/heartbeat/<tag>` is rewritten.
|
||||
const LEGACY_HEARTBEAT = /(\/ext\/heartbeat\/[^/:]+):/;
|
||||
|
||||
export const reroute: Reroute = ({ url }) => {
|
||||
if (LEGACY_HEARTBEAT.test(url.pathname)) {
|
||||
return url.pathname.replace(LEGACY_HEARTBEAT, "$1/");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Permissions derived from src/routes/(manage)/manage/api/+server.ts actions.
|
||||
* Grouped by domain with read/write granularity.
|
||||
*
|
||||
* Mapping from actions → permissions:
|
||||
*
|
||||
* monitors.read → getMonitors, getMonitoringDataPaginated
|
||||
* monitors.write → storeMonitorData, updateMonitoringData, deleteMonitor, deleteMonitorData, cloneMonitor, testMonitor
|
||||
*
|
||||
* incidents.read → getIncidents, getIncident, getComments
|
||||
* incidents.write → createIncident, updateIncident, deleteIncident, addMonitor, removeMonitor, addComment, deleteComment, updateComment
|
||||
*
|
||||
* maintenances.read → getMaintenances, getMaintenance, getMaintenanceEvents, getMaintenanceEvent, getMaintenanceMonitors
|
||||
* maintenances.write → createMaintenance, updateMaintenance, deleteMaintenance, createMaintenanceEvent, updateMaintenanceEvent, updateMaintenanceEventStatus, deleteMaintenanceEvent, addMonitorToMaintenance, removeMonitorFromMaintenance, updateMaintenanceMonitorImpact
|
||||
*
|
||||
* pages.read → getPages
|
||||
* pages.write → createPage, updatePage, deletePage, addMonitorToPage, removeMonitorFromPage, reorderPageMonitors
|
||||
*
|
||||
* triggers.read → getTriggers
|
||||
* triggers.write → createUpdateTrigger, updateMonitorTriggers, deleteTrigger, testTrigger
|
||||
*
|
||||
* alerts.read → getMonitorAlertConfig, getMonitorAlertConfigById, getMonitorAlertConfigsByMonitorTag, getAlertConfigsPaginated, getAllAlertsPaginated
|
||||
* alerts.write → createMonitorAlertConfig, updateMonitorAlertConfig, deleteMonitorAlertConfig, toggleMonitorAlertConfigStatus, deleteMonitorAlertV2, updateMonitorAlertV2Status
|
||||
*
|
||||
* api_keys.read → getAPIKeys
|
||||
* api_keys.write → createNewApiKey, updateApiKeyStatus
|
||||
* api_keys.delete → deleteApiKey (admin-only today)
|
||||
*
|
||||
* users.read → getUsers
|
||||
* users.write → manualUpdate, createNewUser, resendInvitation, sendVerificationEmail
|
||||
*
|
||||
* settings.read → getAllSiteData, getSiteDataByKey, getSubscriptionsConfig
|
||||
* settings.write → storeSiteData, updateSubscriptionsConfig
|
||||
*
|
||||
* subscribers.read → getSubscribersByMethod, getSubscriberWithSubscriptions, getSubscriberCountsByMethod, getAdminSubscribers
|
||||
* subscribers.write → deleteUserSubscription, updateUserSubscriptionStatus, adminUpdateSubscriptionStatus, adminDeleteSubscriber, adminAddSubscriber
|
||||
*
|
||||
* email_templates.read → getGeneralEmailTemplates, getGeneralEmailTemplateById
|
||||
* email_templates.write → updateGeneralEmailTemplate
|
||||
*
|
||||
* images.write → uploadImage, deleteImage
|
||||
*/
|
||||
export const permissions: Array<{ id: string; permission_name: string }> = [
|
||||
// Monitors
|
||||
{ id: "monitors.read", permission_name: "View monitors and monitoring data" },
|
||||
{ id: "monitors.write", permission_name: "Create, update, delete, and clone monitors" },
|
||||
|
||||
// Incidents
|
||||
{ id: "incidents.read", permission_name: "View incidents and comments" },
|
||||
{ id: "incidents.write", permission_name: "Create, update, and delete incidents and comments" },
|
||||
|
||||
// Maintenances
|
||||
{ id: "maintenances.read", permission_name: "View maintenances and events" },
|
||||
{ id: "maintenances.write", permission_name: "Create, update, and delete maintenances and events" },
|
||||
|
||||
// Pages
|
||||
{ id: "pages.read", permission_name: "View pages" },
|
||||
{ id: "pages.write", permission_name: "Create, update, and delete pages" },
|
||||
|
||||
// Triggers
|
||||
{ id: "triggers.read", permission_name: "View triggers" },
|
||||
{ id: "triggers.write", permission_name: "Create, update, delete, and test triggers" },
|
||||
|
||||
// Alerts
|
||||
{ id: "alerts.read", permission_name: "View alert configurations and alert history" },
|
||||
{ id: "alerts.write", permission_name: "Create, update, and delete alert configurations" },
|
||||
|
||||
// API Keys
|
||||
{ id: "api_keys.read", permission_name: "View API keys" },
|
||||
{ id: "api_keys.write", permission_name: "Create and update API keys" },
|
||||
{ id: "api_keys.delete", permission_name: "Delete API keys" },
|
||||
|
||||
// Users
|
||||
{ id: "users.read", permission_name: "View users" },
|
||||
{ id: "users.write", permission_name: "Manage users, invitations, and verification" },
|
||||
|
||||
// Settings (site data + subscriptions config)
|
||||
{ id: "settings.read", permission_name: "View site settings and subscriptions config" },
|
||||
{ id: "settings.write", permission_name: "Update site settings and subscriptions config" },
|
||||
|
||||
// Subscribers
|
||||
{ id: "subscribers.read", permission_name: "View subscribers" },
|
||||
{ id: "subscribers.write", permission_name: "Manage subscribers and subscriptions" },
|
||||
|
||||
// Email Templates
|
||||
{ id: "email_templates.read", permission_name: "View email templates" },
|
||||
{ id: "email_templates.write", permission_name: "Update email templates" },
|
||||
|
||||
// Images
|
||||
{ id: "images.write", permission_name: "Upload and delete images" },
|
||||
|
||||
// Roles
|
||||
{ id: "roles.read", permission_name: "View roles, permissions, and user assignments" },
|
||||
{ id: "roles.write", permission_name: "Create, update, and delete roles" },
|
||||
{ id: "roles.assign_permissions", permission_name: "Add and remove permissions from roles" },
|
||||
{ id: "roles.assign_users", permission_name: "Add and remove users to and from roles" },
|
||||
];
|
||||
|
||||
export const ACTION_PERMISSION_MAP: Record<string, string | null> = {
|
||||
// Self-actions — no permission needed beyond being logged in
|
||||
updateUser: null,
|
||||
updatePassword: null,
|
||||
sendVerificationEmail: null, // controller has its own self-vs-other check
|
||||
|
||||
// Settings
|
||||
getAllSiteData: "settings.read",
|
||||
getSiteDataByKey: "settings.read",
|
||||
getSubscriptionsConfig: "settings.read",
|
||||
storeSiteData: "settings.write",
|
||||
updateSubscriptionsConfig: "settings.write",
|
||||
|
||||
// Users
|
||||
getUsers: "users.read",
|
||||
manualUpdate: "users.write",
|
||||
createNewUser: "users.write",
|
||||
resendInvitation: "users.write",
|
||||
|
||||
// Monitors
|
||||
getMonitors: "monitors.read",
|
||||
getMonitoringDataPaginated: "monitors.read",
|
||||
storeMonitorData: "monitors.write",
|
||||
updateMonitoringData: "monitors.write",
|
||||
deleteMonitor: "monitors.write",
|
||||
deleteMonitorData: "monitors.write",
|
||||
cloneMonitor: "monitors.write",
|
||||
testMonitor: "monitors.write",
|
||||
|
||||
// Incidents
|
||||
getIncidents: "incidents.read",
|
||||
getIncident: "incidents.read",
|
||||
getComments: "incidents.read",
|
||||
createIncident: "incidents.write",
|
||||
updateIncident: "incidents.write",
|
||||
deleteIncident: "incidents.write",
|
||||
addMonitor: "incidents.write",
|
||||
removeMonitor: "incidents.write",
|
||||
addComment: "incidents.write",
|
||||
deleteComment: "incidents.write",
|
||||
updateComment: "incidents.write",
|
||||
|
||||
// Maintenances
|
||||
getMaintenances: "maintenances.read",
|
||||
getMaintenance: "maintenances.read",
|
||||
getMaintenanceEvents: "maintenances.read",
|
||||
getMaintenanceEvent: "maintenances.read",
|
||||
getMaintenanceMonitors: "maintenances.read",
|
||||
createMaintenance: "maintenances.write",
|
||||
updateMaintenance: "maintenances.write",
|
||||
deleteMaintenance: "maintenances.write",
|
||||
createMaintenanceEvent: "maintenances.write",
|
||||
updateMaintenanceEvent: "maintenances.write",
|
||||
updateMaintenanceEventStatus: "maintenances.write",
|
||||
deleteMaintenanceEvent: "maintenances.write",
|
||||
addMonitorToMaintenance: "maintenances.write",
|
||||
removeMonitorFromMaintenance: "maintenances.write",
|
||||
updateMaintenanceMonitorImpact: "maintenances.write",
|
||||
|
||||
// Pages
|
||||
getPages: "pages.read",
|
||||
createPage: "pages.write",
|
||||
updatePage: "pages.write",
|
||||
deletePage: "pages.write",
|
||||
addMonitorToPage: "pages.write",
|
||||
removeMonitorFromPage: "pages.write",
|
||||
reorderPageMonitors: "pages.write",
|
||||
|
||||
// Triggers
|
||||
getTriggers: "triggers.read",
|
||||
createUpdateTrigger: "triggers.write",
|
||||
updateMonitorTriggers: "triggers.write",
|
||||
deleteTrigger: "triggers.write",
|
||||
testTrigger: "triggers.write",
|
||||
|
||||
// Alerts
|
||||
getAllAlertsPaginated: "alerts.read",
|
||||
getMonitorAlertConfig: "alerts.read",
|
||||
getMonitorAlertConfigById: "alerts.read",
|
||||
getMonitorAlertConfigsByMonitorTag: "alerts.read",
|
||||
getAlertConfigsPaginated: "alerts.read",
|
||||
createMonitorAlertConfig: "alerts.write",
|
||||
updateMonitorAlertConfig: "alerts.write",
|
||||
deleteMonitorAlertConfig: "alerts.write",
|
||||
toggleMonitorAlertConfigStatus: "alerts.write",
|
||||
deleteMonitorAlertV2: "alerts.write",
|
||||
updateMonitorAlertV2Status: "alerts.write",
|
||||
|
||||
// API Keys
|
||||
getAPIKeys: "api_keys.read",
|
||||
createNewApiKey: "api_keys.write",
|
||||
updateApiKeyStatus: "api_keys.write",
|
||||
deleteApiKey: "api_keys.delete",
|
||||
|
||||
// Subscribers
|
||||
getSubscribersByMethod: "subscribers.read",
|
||||
getSubscriberWithSubscriptions: "subscribers.read",
|
||||
getSubscriberCountsByMethod: "subscribers.read",
|
||||
getAdminSubscribers: "subscribers.read",
|
||||
deleteUserSubscription: "subscribers.write",
|
||||
updateUserSubscriptionStatus: "subscribers.write",
|
||||
adminUpdateSubscriptionStatus: "subscribers.write",
|
||||
adminDeleteSubscriber: "subscribers.write",
|
||||
adminAddSubscriber: "subscribers.write",
|
||||
|
||||
// Email Templates
|
||||
getGeneralEmailTemplates: "email_templates.read",
|
||||
getGeneralEmailTemplateById: "email_templates.read",
|
||||
updateGeneralEmailTemplate: "email_templates.write",
|
||||
|
||||
// Images
|
||||
uploadImage: "images.write",
|
||||
deleteImage: "images.write",
|
||||
|
||||
// Roles
|
||||
getRoles: "roles.read",
|
||||
getAllPermissions: "roles.read",
|
||||
getRolePermissions: "roles.read",
|
||||
getRoleUsers: "roles.read",
|
||||
createRole: "roles.write",
|
||||
updateRole: "roles.write",
|
||||
deleteRole: "roles.write",
|
||||
updateRolePermissions: "roles.assign_permissions",
|
||||
addUserToRole: "roles.assign_users",
|
||||
removeUserFromRole: "roles.assign_users",
|
||||
};
|
||||
|
||||
export const ROUTE_PERMISSION_MAP: Record<string, string | null> = {
|
||||
// Monitors
|
||||
"/(manage)/manage/app/monitors": "monitors.read",
|
||||
"/(manage)/manage/app/monitors/[tag]": "monitors.read",
|
||||
"/(manage)/manage/app/monitoring-data": "monitors.read",
|
||||
|
||||
// Incidents
|
||||
"/(manage)/manage/app/incidents": "incidents.read",
|
||||
"/(manage)/manage/app/incidents/[incident_id]": "incidents.read",
|
||||
|
||||
// Maintenances
|
||||
"/(manage)/manage/app/maintenances": "maintenances.read",
|
||||
"/(manage)/manage/app/maintenances/[id]": "maintenances.read",
|
||||
|
||||
// Pages
|
||||
"/(manage)/manage/app/pages": "pages.read",
|
||||
"/(manage)/manage/app/pages/[page_id]": "pages.read",
|
||||
|
||||
// Triggers
|
||||
"/(manage)/manage/app/triggers": "triggers.read",
|
||||
"/(manage)/manage/app/triggers/[trigger_id]": "triggers.read",
|
||||
|
||||
// Alerts
|
||||
"/(manage)/manage/app/alerts": "alerts.read",
|
||||
"/(manage)/manage/app/alerts/[alert_config_id]": "alerts.read",
|
||||
"/(manage)/manage/app/alerts/logs/[alert_config_id]": "alerts.read",
|
||||
|
||||
// API Keys
|
||||
"/(manage)/manage/app/api-keys": "api_keys.read",
|
||||
|
||||
// Users
|
||||
"/(manage)/manage/app/users": "users.read",
|
||||
|
||||
// Settings
|
||||
"/(manage)/manage/app/site-configurations": "settings.read",
|
||||
"/(manage)/manage/app/customizations": "settings.read",
|
||||
"/(manage)/manage/app/internationalization": "settings.read",
|
||||
"/(manage)/manage/app/analytics-providers": "settings.read",
|
||||
"/(manage)/manage/app/badges": "settings.read",
|
||||
"/(manage)/manage/app/embed": "settings.read",
|
||||
|
||||
// Subscribers
|
||||
"/(manage)/manage/app/subscriptions": "subscribers.read",
|
||||
|
||||
// Email Templates
|
||||
"/(manage)/manage/app/templates": "email_templates.read",
|
||||
|
||||
// Roles
|
||||
"/(manage)/manage/app/roles": "roles.read",
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import type { NotificationEvent } from "$lib/types/notifications.js";
|
||||
|
||||
interface NotificationsResponse {
|
||||
notifications?: NotificationEvent[];
|
||||
}
|
||||
|
||||
export async function requestNotifications(monitorTags: string[] = []): Promise<NotificationEvent[]> {
|
||||
const query = monitorTags.length > 0 ? `?tags=${encodeURIComponent(monitorTags.join(","))}` : "";
|
||||
const response = await fetch(clientResolver(resolve, "/dashboard-apis/notifications") + query);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch notifications");
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as NotificationsResponse;
|
||||
return payload.notifications || [];
|
||||
}
|
||||
@@ -31,3 +31,39 @@ export default function urlResolve(resolve: ResolveFn, path: string, params?: Re
|
||||
}
|
||||
return resolve(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a path to an absolute URL by prefixing the site URL.
|
||||
* Required for meta tags like og:image and twitter:image that need absolute URLs.
|
||||
* @param resolve - The resolve function from $app/paths
|
||||
* @param siteUrl - The site URL (e.g., "https://status.example.com")
|
||||
* @param path - The route path or absolute URL
|
||||
* @param params - Optional parameters for dynamic route segments
|
||||
* @returns An absolute URL, or the resolved relative URL if siteUrl is empty
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* absoluteResolve(resolve, "https://status.example.com", "/uploads/preview.png")
|
||||
* // => "https://status.example.com/uploads/preview.png"
|
||||
* ```
|
||||
*/
|
||||
export function absoluteResolve(
|
||||
resolve: ResolveFn,
|
||||
siteUrl: string,
|
||||
path: string,
|
||||
params?: Record<string, string>
|
||||
): string {
|
||||
// Normalize relative paths like "./assets/..." to "/assets/..." so the
|
||||
// final URL doesn't contain "/./" segments (crawlers don't normalize these)
|
||||
const normalizedPath = path.startsWith("./") ? path.slice(1) : path;
|
||||
const resolved = urlResolve(resolve, normalizedPath, params);
|
||||
// Already absolute, return as-is
|
||||
if (resolved.startsWith("http://") || resolved.startsWith("https://")) {
|
||||
return resolved;
|
||||
}
|
||||
if (!siteUrl) {
|
||||
return resolved;
|
||||
}
|
||||
const trimmedSiteUrl = siteUrl.replace(/\/+$/, "");
|
||||
return trimmedSiteUrl + (resolved.startsWith("/") ? resolved : "/" + resolved);
|
||||
}
|
||||
|
||||
+42
-34
@@ -1,5 +1,5 @@
|
||||
import type { TimestampStatusCount } from "$lib/server/types/db";
|
||||
import { PAGE_STATUS_MESSAGES } from "$lib/global-constants";
|
||||
import GC, { PAGE_STATUS_MESSAGES, type StatusType } from "$lib/global-constants";
|
||||
|
||||
function ParseLatency(latencyMs: number): string {
|
||||
if (!!!latencyMs) {
|
||||
@@ -316,46 +316,53 @@ interface GameItem {
|
||||
function GetGameFromId(list: GameItem[], id: string): GameItem | undefined {
|
||||
return list.find((game: GameItem) => game.id === id);
|
||||
}
|
||||
type StatusCounts = Pick<TimestampStatusCount, "countOfUp" | "countOfDown" | "countOfDegraded" | "countOfMaintenance">;
|
||||
|
||||
// Canonical Overall Status collapse: the worst state wins, and maintenance
|
||||
// never masks an active problem. See docs/adr/0007-problem-first-overall-status.md.
|
||||
function CollapseStatusCounts(counts: StatusCounts): StatusType {
|
||||
const total = counts.countOfUp + counts.countOfDown + counts.countOfDegraded + counts.countOfMaintenance;
|
||||
if (total === 0) return GC.NO_DATA;
|
||||
if (counts.countOfDown > 0) return GC.DOWN;
|
||||
if (counts.countOfDegraded > 0) return GC.DEGRADED;
|
||||
if (counts.countOfMaintenance > 0) return GC.MAINTENANCE;
|
||||
return GC.UP;
|
||||
}
|
||||
|
||||
function GetStatusSummary(item: TimestampStatusCount): string {
|
||||
const total = item.countOfUp + item.countOfDown + item.countOfDegraded + item.countOfMaintenance;
|
||||
if (total === 0) return PAGE_STATUS_MESSAGES.NO_DATA;
|
||||
|
||||
const maintenancePercent = (item.countOfMaintenance / total) * 100;
|
||||
const downPercent = (item.countOfDown / total) * 100;
|
||||
const degradedPercent = (item.countOfDegraded / total) * 100;
|
||||
|
||||
if (maintenancePercent > 0) {
|
||||
return PAGE_STATUS_MESSAGES.UNDER_MAINTENANCE;
|
||||
} else if (downPercent >= 75) {
|
||||
return PAGE_STATUS_MESSAGES.MAJOR_OUTAGE;
|
||||
} else if (downPercent >= 50) {
|
||||
return PAGE_STATUS_MESSAGES.PARTIAL_OUTAGE;
|
||||
} else if (item.countOfDown > 0) {
|
||||
return PAGE_STATUS_MESSAGES.PARTIAL_OUTAGE;
|
||||
} else if (degradedPercent >= 75) {
|
||||
return PAGE_STATUS_MESSAGES.DEGRADED_PERFORMANCE;
|
||||
} else if (degradedPercent >= 50) {
|
||||
return PAGE_STATUS_MESSAGES.PARTIAL_DEGRADED;
|
||||
} else if (item.countOfDegraded > 0) {
|
||||
return PAGE_STATUS_MESSAGES.PARTIAL_DEGRADED;
|
||||
} else if (item.countOfUp === total) {
|
||||
return PAGE_STATUS_MESSAGES.ALL_OPERATIONAL;
|
||||
switch (CollapseStatusCounts(item)) {
|
||||
case GC.DOWN:
|
||||
return (item.countOfDown / total) * 100 >= 75
|
||||
? PAGE_STATUS_MESSAGES.MAJOR_OUTAGE
|
||||
: PAGE_STATUS_MESSAGES.PARTIAL_OUTAGE;
|
||||
case GC.DEGRADED:
|
||||
return (item.countOfDegraded / total) * 100 >= 75
|
||||
? PAGE_STATUS_MESSAGES.DEGRADED_PERFORMANCE
|
||||
: PAGE_STATUS_MESSAGES.PARTIAL_DEGRADED;
|
||||
case GC.MAINTENANCE:
|
||||
return PAGE_STATUS_MESSAGES.UNDER_MAINTENANCE;
|
||||
case GC.UP:
|
||||
return PAGE_STATUS_MESSAGES.ALL_OPERATIONAL;
|
||||
default:
|
||||
return PAGE_STATUS_MESSAGES.NO_DATA;
|
||||
}
|
||||
|
||||
return PAGE_STATUS_MESSAGES.NO_DATA;
|
||||
}
|
||||
|
||||
function GetStatusColor(item: TimestampStatusCount): string {
|
||||
const total = item.countOfUp + item.countOfDown + item.countOfDegraded + item.countOfMaintenance;
|
||||
if (total === 0) return "text-muted-foreground";
|
||||
|
||||
const maintenancePercent = (item.countOfMaintenance / total) * 100;
|
||||
const downPercent = (item.countOfDown / total) * 100;
|
||||
|
||||
if (maintenancePercent > 0) return "text-maintenance";
|
||||
if (downPercent > 0) return "text-down";
|
||||
if (item.countOfDegraded > 0) return "text-degraded";
|
||||
return "text-up";
|
||||
switch (CollapseStatusCounts(item)) {
|
||||
case GC.DOWN:
|
||||
return "text-down";
|
||||
case GC.DEGRADED:
|
||||
return "text-degraded";
|
||||
case GC.MAINTENANCE:
|
||||
return "text-maintenance";
|
||||
case GC.UP:
|
||||
return "text-up";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
function GetStatusBgColor(item: TimestampStatusCount): string {
|
||||
@@ -378,6 +385,7 @@ export {
|
||||
IsValidNameServer,
|
||||
IsValidURL,
|
||||
IsValidPort,
|
||||
CollapseStatusCounts,
|
||||
GetStatusSummary,
|
||||
GetStatusColor,
|
||||
GetStatusBgColor,
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||
import Clock from "@lucide/svelte/icons/clock";
|
||||
import CalendarClock from "@lucide/svelte/icons/calendar-clock";
|
||||
import Timer from "@lucide/svelte/icons/timer";
|
||||
import { t } from "$lib/stores/i18n";
|
||||
import { formatDate, formatDuration } from "$lib/stores/datetime";
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
|
||||
interface Maintenance {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
start_date_time: number;
|
||||
end_date_time: number;
|
||||
monitor_tag?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ongoingMaintenances?: Maintenance[];
|
||||
upcomingMaintenances?: Maintenance[];
|
||||
pastMaintenances?: Maintenance[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
ongoingMaintenances = [],
|
||||
upcomingMaintenances = [],
|
||||
pastMaintenances = [],
|
||||
class: className = ""
|
||||
}: Props = $props();
|
||||
|
||||
// Deduplicate maintenances by id (can have duplicates due to multiple monitors)
|
||||
function deduplicateMaintenances(maintenances: Maintenance[]): Maintenance[] {
|
||||
const seen = new Set<number>();
|
||||
return maintenances.filter((m) => {
|
||||
if (seen.has(m.id)) return false;
|
||||
seen.add(m.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Deduplicated arrays
|
||||
let uniqueOngoing = $derived(deduplicateMaintenances(ongoingMaintenances));
|
||||
let uniqueUpcoming = $derived(deduplicateMaintenances(upcomingMaintenances));
|
||||
let uniquePast = $derived(deduplicateMaintenances(pastMaintenances));
|
||||
|
||||
// Check if there's any maintenance data
|
||||
let hasAnyData = $derived(uniqueOngoing.length > 0 || uniqueUpcoming.length > 0 || uniquePast.length > 0);
|
||||
</script>
|
||||
|
||||
{#if hasAnyData}
|
||||
<div class="bg-background rounded-3xl border {className}">
|
||||
<div class=" flex items-center justify-between p-4">
|
||||
<Badge variant="secondary" class="gap-1">{$t("Maintenances")}</Badge>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-0 lg:grid-cols-3">
|
||||
<!-- Ongoing Maintenances -->
|
||||
<div class="flex flex-col lg:border-r">
|
||||
<div class="text-muted-foreground bg-secondary flex items-center justify-between gap-2 p-4 text-sm font-medium">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="bg-maintenance h-2 w-2 rounded-full"></div>
|
||||
{$t("Ongoing")}
|
||||
</div>
|
||||
<div class="text-maintenance">
|
||||
<span>{uniqueOngoing.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scrollbar-hidden max-h-64 overflow-y-auto">
|
||||
{#if uniqueOngoing.length === 0}
|
||||
<p class="text-muted-foreground py-4 text-center text-xs">{$t("No ongoing maintenances")}</p>
|
||||
{:else}
|
||||
{#each uniqueOngoing as maintenance (maintenance.id)}
|
||||
<a
|
||||
href={clientResolver(resolve, `/maintenances/${maintenance.id}`)}
|
||||
class="hover:bg-muted/50 block border-b p-3 transition-colors last:border-0"
|
||||
>
|
||||
<h4 class="line-clamp-2 text-sm leading-tight font-medium">{maintenance.title}</h4>
|
||||
{#if maintenance.description}
|
||||
<p class="text-muted-foreground mt-1 line-clamp-3 text-xs leading-relaxed">
|
||||
{maintenance.description}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span class="flex items-center gap-1">
|
||||
<Clock class="h-3 w-3" />
|
||||
{$formatDate(maintenance.start_date_time, "MMM d, HH:mm")}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<Timer class="h-3 w-3" />
|
||||
{$formatDuration(maintenance.start_date_time, maintenance.end_date_time)}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upcoming Maintenances -->
|
||||
<div class="flex flex-col lg:border-r">
|
||||
<div class="text-muted-foreground bg-secondary flex items-center justify-between gap-2 p-4 text-sm font-medium">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="bg-primary h-2 w-2 rounded-full"></div>
|
||||
{$t("Upcoming")}
|
||||
</div>
|
||||
<div>
|
||||
{uniqueUpcoming.length}
|
||||
</div>
|
||||
</div>
|
||||
<div class="scrollbar-hidden max-h-64 overflow-y-auto">
|
||||
{#if uniqueUpcoming.length === 0}
|
||||
<p class="text-muted-foreground py-4 text-center text-xs">{$t("No upcoming maintenances")}</p>
|
||||
{:else}
|
||||
{#each uniqueUpcoming as maintenance (maintenance.id)}
|
||||
<a
|
||||
href={clientResolver(resolve, `/maintenances/${maintenance.id}`)}
|
||||
class="hover:bg-muted/50 block border-b p-3 transition-colors last:border-0"
|
||||
>
|
||||
<h4 class="line-clamp-2 text-sm leading-tight font-medium">{maintenance.title}</h4>
|
||||
{#if maintenance.description}
|
||||
<p class="text-muted-foreground mt-1 line-clamp-3 text-xs leading-relaxed">
|
||||
{maintenance.description}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span class="flex items-center gap-1">
|
||||
<CalendarClock class="h-3 w-3" />
|
||||
{$formatDate(maintenance.start_date_time, "MMM d, HH:mm")}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<Timer class="h-3 w-3" />
|
||||
{$formatDuration(maintenance.start_date_time, maintenance.end_date_time)}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Past Maintenances -->
|
||||
<div class="flex flex-col">
|
||||
<div class="text-muted-foreground bg-secondary flex items-center justify-between gap-2 p-4 text-sm font-medium">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="bg-muted-foreground h-2 w-2 rounded-full"></div>
|
||||
|
||||
{$t("Past")}
|
||||
</div>
|
||||
<div>
|
||||
{uniquePast.length}
|
||||
</div>
|
||||
</div>
|
||||
<div class="scrollbar-hidden max-h-64 overflow-y-auto">
|
||||
{#if uniquePast.length === 0}
|
||||
<p class="text-muted-foreground py-4 text-center text-xs">{$t("No past maintenances")}</p>
|
||||
{:else}
|
||||
{#each uniquePast as maintenance (maintenance.id)}
|
||||
<a
|
||||
href={clientResolver(resolve, `/maintenances/${maintenance.id}`)}
|
||||
class="hover:bg-muted/50 block border-b p-3 transition-colors last:border-0"
|
||||
>
|
||||
<h4 class="line-clamp-2 text-sm leading-tight font-medium">{maintenance.title}</h4>
|
||||
{#if maintenance.description}
|
||||
<p class="text-muted-foreground mt-1 line-clamp-3 text-xs leading-relaxed">
|
||||
{maintenance.description}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span class="flex items-center gap-1">
|
||||
<Clock class="h-3 w-3" />
|
||||
{$formatDate(maintenance.start_date_time, "MMM d, HH:mm")}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<Timer class="h-3 w-3" />
|
||||
{$formatDuration(maintenance.start_date_time, maintenance.end_date_time)}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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}
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
class="mt-2 flex w-full flex-col gap-2 text-xs font-medium sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span class="max-w-full rounded-full border px-3 py-2 wrap-break-word">
|
||||
{$formatDate(incident.start_date_time, "PPp")}
|
||||
{$formatDate(incident.start_date_time, page.data.dateAndTimeFormat.datePlusTime)}
|
||||
</span>
|
||||
<span class="relative w-full text-center sm:flex-1">
|
||||
<span
|
||||
@@ -117,7 +117,7 @@
|
||||
</span>
|
||||
{#if incident.end_date_time}
|
||||
<span class="max-w-full rounded-full border px-3 py-2 wrap-break-word">
|
||||
{$formatDate(incident.end_date_time, "PPp")}
|
||||
{$formatDate(incident.end_date_time, page.data.dateAndTimeFormat.datePlusTime)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="max-w-full rounded-full border px-3 py-2 wrap-break-word">
|
||||
@@ -129,7 +129,7 @@
|
||||
<div class="my-2 grid grid-cols-1 gap-4 text-xs font-medium sm:grid-cols-3">
|
||||
<div class="text-muted-foreground bg-secondary flex items-center justify-between rounded-full border p-2 px-4">
|
||||
<span>{$t("Last Updated")}</span>
|
||||
<span>{$formatDate(incident.updated_at, "PPp")}</span>
|
||||
<span>{$formatDate(incident.updated_at, page.data.dateAndTimeFormat.datePlusTime)}</span>
|
||||
</div>
|
||||
<div class="text-muted-foreground bg-secondary flex items-center justify-between rounded-full border p-2 px-4">
|
||||
<span>{$t("Status")}</span>
|
||||
@@ -167,7 +167,7 @@
|
||||
{$t(comment.state)}
|
||||
</Badge>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{$formatDate(comment.commented_at, "PPp")}
|
||||
{$formatDate(comment.commented_at, page.data.dateAndTimeFormat.datePlusTime)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { t } from "$lib/stores/i18n";
|
||||
import { ParseLatency } from "$lib/clientTools";
|
||||
import { formatDate } from "$lib/stores/datetime";
|
||||
import { page } from "$app/state";
|
||||
|
||||
interface ChartPoint {
|
||||
date: Date;
|
||||
@@ -82,7 +83,9 @@
|
||||
></div>
|
||||
<div class="flex flex-1 flex-col items-start justify-between gap-1 leading-none">
|
||||
<span class="text-muted-foreground text-xs"
|
||||
>{item.payload?.date ? $formatDate(item.payload.date, "MMM d") : ""}</span
|
||||
>{item.payload?.date
|
||||
? $formatDate(item.payload.date, page.data.dateAndTimeFormat.dateOnly)
|
||||
: ""}</span
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-foreground font-mono font-medium tabular-nums">
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import { GetInitials } from "$lib/clientTools.js";
|
||||
import type { MaintenanceEventsMonitorList } from "$lib/server/types/db";
|
||||
import { SveltePurify } from "@humanspeak/svelte-purify";
|
||||
import mdToHTML from "$lib/marked";
|
||||
import { page } from "$app/state";
|
||||
|
||||
interface Props {
|
||||
@@ -42,9 +44,11 @@
|
||||
</div>
|
||||
|
||||
{#if maintenance.description}
|
||||
<p class="text-muted-foreground mt-1 text-sm">
|
||||
{maintenance.description}
|
||||
</p>
|
||||
<div
|
||||
class="prose prose-sm dark:prose-invert text-muted-foreground mt-1 max-w-none min-w-0 overflow-x-auto text-sm wrap-break-word"
|
||||
>
|
||||
<SveltePurify html={mdToHTML(maintenance.description)} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if maintenance.monitors && maintenance.monitors.length > 0 && !hideMonitors}
|
||||
@@ -97,7 +101,7 @@
|
||||
class="mt-2 flex w-full flex-col gap-2 text-xs font-medium sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span class="max-w-full rounded-full border px-3 py-2 wrap-break-word">
|
||||
{$formatDate(maintenance.start_date_time, "PPp")}
|
||||
{$formatDate(maintenance.start_date_time, page.data.dateAndTimeFormat.datePlusTime)}
|
||||
</span>
|
||||
<span class="relative w-full text-center sm:flex-1">
|
||||
<span
|
||||
@@ -108,7 +112,7 @@
|
||||
</span>
|
||||
</span>
|
||||
<span class="max-w-full rounded-full border px-3 py-2 wrap-break-word">
|
||||
{$formatDate(maintenance.end_date_time, "PPp")}
|
||||
{$formatDate(maintenance.end_date_time, page.data.dateAndTimeFormat.datePlusTime)}
|
||||
</span>
|
||||
</Item.Description>
|
||||
</Item.Content>
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import TrendingUp from "@lucide/svelte/icons/trending-up";
|
||||
import { t } from "$lib/stores/i18n";
|
||||
import { formatDate } from "$lib/stores/datetime";
|
||||
import { selectedTimezone } from "$lib/stores/timezone";
|
||||
import { toZonedTime } from "date-fns-tz";
|
||||
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
|
||||
|
||||
interface MinuteData {
|
||||
@@ -74,7 +76,7 @@
|
||||
const minutesByHour: Map<number, MinuteData[]> = new Map();
|
||||
|
||||
for (const minute of minutes) {
|
||||
const date = new Date(minute.timestamp * 1000);
|
||||
const date = toZonedTime(minute.timestamp * 1000, $selectedTimezone);
|
||||
const hour = date.getHours();
|
||||
|
||||
if (!minutesByHour.has(hour)) {
|
||||
@@ -319,7 +321,10 @@
|
||||
style={tooltipStyle}
|
||||
>
|
||||
<span class="text-{hoveredMinute.data.status.toLowerCase()}">
|
||||
{$t(hoveredMinute.data.status)} @ {$formatDate(hoveredMinute.data.timestamp, "HH:mm")}
|
||||
{$t(hoveredMinute.data.status)} @ {$formatDate(
|
||||
hoveredMinute.data.timestamp,
|
||||
page.data.dateAndTimeFormat.timeOnly
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import * as Item from "$lib/components/ui/item/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
|
||||
import * as Avatar from "$lib/components/ui/avatar/index.js";
|
||||
import ICONS from "$lib/icons";
|
||||
@@ -12,6 +11,7 @@
|
||||
import { GetInitials } from "$lib/clientTools.js";
|
||||
import GroupMonitorPopover from "./GroupMonitorPopover.svelte";
|
||||
import { t } from "$lib/stores/i18n";
|
||||
import { page } from "$app/state";
|
||||
|
||||
interface Props {
|
||||
tag: string;
|
||||
@@ -136,11 +136,11 @@
|
||||
<StatusBarCalendar data={data.uptimeData} monitorTag={tag} barHeight={40} radius={8} />
|
||||
<div class="flex min-w-0 justify-between gap-3">
|
||||
<p class="text-muted-foreground min-w-0 truncate text-xs font-medium">
|
||||
{$formatDate(new Date(data.fromTimeStamp * 1000), "MMM d, yyyy")}
|
||||
{$formatDate(new Date(data.fromTimeStamp * 1000), page.data.dateAndTimeFormat.dateOnly)}
|
||||
</p>
|
||||
|
||||
<p class="text-muted-foreground min-w-0 truncate text-right text-xs font-medium">
|
||||
{$formatDate(new Date(data.toTimeStamp * 1000), "MMM d, yyyy")}
|
||||
{$formatDate(new Date(data.toTimeStamp * 1000), page.data.dateAndTimeFormat.dateOnly)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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}
|
||||
|
||||
@@ -3,24 +3,20 @@
|
||||
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
|
||||
|
||||
import { resolve } from "$app/paths";
|
||||
import TrendingUp from "@lucide/svelte/icons/trending-up";
|
||||
import Clock from "@lucide/svelte/icons/clock";
|
||||
import Activity from "@lucide/svelte/icons/activity";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
||||
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
|
||||
import LoaderBoxes from "$lib/components/loaderbox.svelte";
|
||||
import constants from "$lib/global-constants.js";
|
||||
import * as Tabs from "$lib/components/ui/tabs/index.js";
|
||||
import { page } from "$app/state";
|
||||
import { AreaChart, Area, LinearGradient } from "layerchart";
|
||||
import { curveCatmullRom } from "d3-shape";
|
||||
import { scaleOrdinal, scaleSequential, scaleTime } from "d3-scale";
|
||||
import { scaleTime } from "d3-scale";
|
||||
import IncidentItem from "$lib/components/IncidentItem.svelte";
|
||||
import MaintenanceItem from "$lib/components/MaintenanceItem.svelte";
|
||||
import MinuteGrid from "$lib/components/MinuteGrid.svelte";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import { ParseLatency } from "$lib/clientTools";
|
||||
|
||||
import * as Chart from "$lib/components/ui/chart/index.js";
|
||||
import type { IncidentForMonitorListWithComments, MaintenanceEventsMonitorList } from "$lib/server/types/db";
|
||||
@@ -213,9 +209,8 @@
|
||||
<Dialog.Content class="max-h-[90vh] overflow-y-auto rounded-3xl p-4 sm:max-w-[46.5rem] sm:p-6">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2 text-base sm:text-lg">
|
||||
<Activity class="h-4 w-4 shrink-0 sm:h-5 sm:w-5" />
|
||||
<span class="truncate">
|
||||
{selectedDay ? $formatDate(new Date(selectedDay.timestamp * 1000), "EEEE, MMMM do, yyyy") : ""}
|
||||
{selectedDay ? $formatDate(new Date(selectedDay.timestamp * 1000), page.data.dateAndTimeFormat.dateOnly) : ""}
|
||||
</span>
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="text-xs sm:text-sm"
|
||||
@@ -316,7 +311,7 @@
|
||||
line: { class: "stroke-1" }
|
||||
},
|
||||
xAxis: {
|
||||
format: (d: Date) => $formatDate(d, "HH:mm")
|
||||
format: (d: Date) => $formatDate(d, page.data.dateAndTimeFormat.timeOnly)
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -342,11 +337,13 @@
|
||||
></div>
|
||||
<div class="flex flex-1 flex-col items-start justify-between gap-1 leading-none">
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{item.payload?.date ? $formatDate(item.payload.date, "HH:mm") : ""}
|
||||
{item.payload?.date
|
||||
? $formatDate(item.payload.date, page.data.dateAndTimeFormat.timeOnly)
|
||||
: ""}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-foreground font-mono font-medium tabular-nums">
|
||||
{Math.round(Number(value))} ms
|
||||
{ParseLatency(Math.round(Number(value)))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { onMount, untrack } from "svelte";
|
||||
import * as Card from "$lib/components/ui/card/index.js";
|
||||
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
|
||||
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
|
||||
import StatusBarCalendar from "$lib/components/StatusBarCalendar.svelte";
|
||||
import LatencyTrendChart from "$lib/components/LatencyTrendChart.svelte";
|
||||
@@ -18,6 +17,7 @@
|
||||
import * as Popover from "$lib/components/ui/popover/index.js";
|
||||
import * as ToggleGroup from "$lib/components/ui/toggle-group/index.js";
|
||||
import GroupMonitorPopover from "$lib/components/GroupMonitorPopover.svelte";
|
||||
import { page } from "$app/state";
|
||||
|
||||
interface Props {
|
||||
monitorTag: string;
|
||||
@@ -205,12 +205,12 @@
|
||||
<div class="flex justify-between">
|
||||
<p class="text-muted-foreground text-xs font-medium">
|
||||
{#if displayData.length > 0}
|
||||
{$formatDate(displayData[0].ts, "d MMM yyyy")}
|
||||
{$formatDate(displayData[0].ts, page.data.dateAndTimeFormat.dateOnly)}
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-muted-foreground text-xs font-medium">
|
||||
{#if displayData.length > 0}
|
||||
{$formatDate(displayData[displayData.length - 1].ts, "d MMM yyyy")}
|
||||
{$formatDate(displayData[displayData.length - 1].ts, page.data.dateAndTimeFormat.dateOnly)}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import * as Command from "$lib/components/ui/command/index.js";
|
||||
import * as Popover from "$lib/components/ui/popover/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import type { MonitorRecord } from "$lib/server/types/db.js";
|
||||
import CheckIcon from "@lucide/svelte/icons/check";
|
||||
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
|
||||
import ListPlusIcon from "@lucide/svelte/icons/list-plus";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import { resolve } from "$app/paths";
|
||||
|
||||
let {
|
||||
monitors = [],
|
||||
selectedTags = [],
|
||||
onToggle,
|
||||
onAddMany,
|
||||
placeholder = "Search monitors to add..."
|
||||
}: {
|
||||
monitors: MonitorRecord[];
|
||||
selectedTags: string[];
|
||||
onToggle: (tag: string) => void;
|
||||
onAddMany?: (tags: string[]) => void;
|
||||
placeholder?: string;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let search = $state("");
|
||||
|
||||
// Own filtering (shouldFilter={false}) so "Add all matching" counts stay
|
||||
// consistent with what the list shows. Case-insensitive over name + tag.
|
||||
const filteredMonitors = $derived.by(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
if (!query) return monitors;
|
||||
return monitors.filter((m) => m.name.toLowerCase().includes(query) || m.tag.toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
const unselectedMatches = $derived(filteredMonitors.filter((m) => !selectedTags.includes(m.tag)));
|
||||
const showAddAll = $derived(!!search.trim() && unselectedMatches.length > 0 && !!onAddMany);
|
||||
|
||||
function addAllMatching() {
|
||||
onAddMany?.(unselectedMatches.map((m) => m.tag));
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
class="w-full justify-between font-normal"
|
||||
>
|
||||
<span class="text-muted-foreground">{placeholder}</span>
|
||||
<ChevronsUpDownIcon class="text-muted-foreground size-4 shrink-0" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-[var(--bits-popover-trigger-width)] p-0" align="start">
|
||||
<Command.Root shouldFilter={false}>
|
||||
<Command.Input {placeholder} bind:value={search} />
|
||||
<Command.List class="max-h-64">
|
||||
<Command.Empty>No monitors found.</Command.Empty>
|
||||
<Command.Group>
|
||||
{#each filteredMonitors as monitor (monitor.tag)}
|
||||
{@const selected = selectedTags.includes(monitor.tag)}
|
||||
<Command.Item value={monitor.tag} onSelect={() => onToggle(monitor.tag)}>
|
||||
<CheckIcon class="size-4 {selected ? 'opacity-100' : 'opacity-0'}" />
|
||||
{#if monitor.image}
|
||||
<img
|
||||
src={clientResolver(resolve, monitor.image)}
|
||||
alt={monitor.name}
|
||||
class="size-5 rounded object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="bg-muted flex size-5 items-center justify-center rounded text-[10px] font-medium">
|
||||
{monitor.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="truncate">{monitor.name}</span>
|
||||
<span class="text-muted-foreground ml-auto truncate text-xs">{monitor.tag}</span>
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.Group>
|
||||
{#if showAddAll}
|
||||
<Command.Separator />
|
||||
<Command.Group>
|
||||
<Command.Item value="__add-all-matching__" onSelect={addAllMatching}>
|
||||
<ListPlusIcon class="size-4" />
|
||||
Add all {unselectedMatches.length} matching
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
{/if}
|
||||
</Command.List>
|
||||
</Command.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from "$app/paths";
|
||||
import { page } from "$app/state";
|
||||
import { requestNotifications } from "$lib/client/notifications-client.js";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { formatDate, formatDuration } from "$lib/stores/datetime";
|
||||
import { t } from "$lib/stores/i18n";
|
||||
import type { NotificationEvent } from "$lib/types/notifications.js";
|
||||
import Calendar from "@lucide/svelte/icons/calendar-1";
|
||||
import { format } from "date-fns";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
interface Props {
|
||||
monitorTags?: string[];
|
||||
eventsPath?: string;
|
||||
notifications?: NotificationEvent[];
|
||||
loading?: boolean;
|
||||
fetchOnMount?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
monitorTags = [],
|
||||
eventsPath = "",
|
||||
notifications = $bindable<NotificationEvent[]>([]),
|
||||
loading = $bindable(false),
|
||||
fetchOnMount = true
|
||||
}: Props = $props();
|
||||
|
||||
const defaultEventsPath = $derived(`/events/${format(new Date(), "MMMM-yyyy")}`);
|
||||
|
||||
const resolvedEventsPath = $derived.by(() => {
|
||||
const finalEventsPath = eventsPath || defaultEventsPath;
|
||||
if (page.data?.globalPageVisibilitySettings?.forceExclusivity) {
|
||||
const currentPagePath = page.params?.page_path?.trim();
|
||||
return currentPagePath ? `/${currentPagePath}${finalEventsPath}` : finalEventsPath;
|
||||
}
|
||||
return finalEventsPath;
|
||||
});
|
||||
|
||||
async function fetchNotifications() {
|
||||
loading = true;
|
||||
try {
|
||||
notifications = await requestNotifications(monitorTags);
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (fetchOnMount) {
|
||||
void fetchNotifications();
|
||||
}
|
||||
});
|
||||
|
||||
function getEventId(eventURL: string) {
|
||||
return eventURL.split("/").filter(Boolean).at(-1) || "";
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet notificationItem(item: NotificationEvent)}
|
||||
<div class="my-0.5 flex items-center justify-between gap-2 text-xs">
|
||||
<span class="text-muted-foreground text-[11px] uppercase">{$t(item.eventType)}</span>
|
||||
<span class="text-{item.eventStatus.toLowerCase()}">{$t(item.eventStatus)}</span>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="line-clamp-2 text-sm">{item.eventTitle}</p>
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-1 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span>{$formatDate(item.eventDate, page.data.dateAndTimeFormat.datePlusTime)}</span>
|
||||
|
||||
<span>•</span>
|
||||
<span>{$formatDuration(item.eventStartDateTime, item.eventEndDateTime, $t("Ongoing"))}</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h4 class="text-sm font-semibold">{$t("Events")}</h4>
|
||||
<Button
|
||||
variant="outline"
|
||||
href={clientResolver(resolve, resolvedEventsPath)}
|
||||
size="icon-sm"
|
||||
class="rounded-btn"
|
||||
aria-label={$t("Open events page")}
|
||||
title={$t("Open events page")}
|
||||
>
|
||||
<Calendar class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if notifications.length === 0}
|
||||
<div class="text-muted-foreground px-4 py-6 text-center text-sm">
|
||||
{$t("No events to show")}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="scrollbar-hidden max-h-96 overflow-y-auto">
|
||||
{#each notifications as item, i (`${item.eventType}-${item.eventURL}-${item.eventDate}-${i}`)}
|
||||
{@const eventId = getEventId(item.eventURL)}
|
||||
{#if item.eventURL.startsWith("/incidents/")}
|
||||
<a
|
||||
href={resolve("/(kener)/incidents/[incident_id]", { incident_id: eventId })}
|
||||
class="hover:bg-muted/60 block border-b px-4 py-3 last:border-b-0"
|
||||
>
|
||||
{@render notificationItem(item)}
|
||||
</a>
|
||||
{:else}
|
||||
<a
|
||||
href={resolve("/(kener)/maintenances/[maintenance_id]", { maintenance_id: eventId })}
|
||||
class="hover:bg-muted/60 block border-b px-4 py-3 last:border-b-0"
|
||||
>
|
||||
{@render notificationItem(item)}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,22 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from "$app/paths";
|
||||
import { page } from "$app/state";
|
||||
import NotificationsList from "$lib/components/NotificationsList.svelte";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import * as Popover from "$lib/components/ui/popover/index.js";
|
||||
import { Spinner } from "$lib/components/ui/spinner/index.js";
|
||||
import { requestNotifications } from "$lib/client/notifications-client.js";
|
||||
import ICONS from "$lib/icons";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import { formatDate, formatDuration } from "$lib/stores/datetime";
|
||||
import { t } from "$lib/stores/i18n";
|
||||
import type { NotificationEvent } from "$lib/server/controllers/dashboardController.js";
|
||||
import Calendar from "@lucide/svelte/icons/calendar-1";
|
||||
import { format } from "date-fns";
|
||||
import type { NotificationEvent } from "$lib/types/notifications.js";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
interface Props {
|
||||
monitorTags?: string[];
|
||||
compact?: boolean;
|
||||
eventsPath: string;
|
||||
eventsPath?: string;
|
||||
}
|
||||
|
||||
let { monitorTags = [], compact = true, eventsPath = "" }: Props = $props();
|
||||
@@ -24,26 +20,10 @@
|
||||
let notifications = $state<NotificationEvent[]>([]);
|
||||
let loading = $state(false);
|
||||
|
||||
const defaultEventsPath = $derived(`/events/${format(new Date(), "MMMM-yyyy")}`);
|
||||
|
||||
const resolvedEventsPath = $derived.by(() => {
|
||||
const finalEventsPath = eventsPath || defaultEventsPath;
|
||||
if (page.data?.globalPageVisibilitySettings?.forceExclusivity) {
|
||||
const currentPagePath = page.params?.page_path?.trim();
|
||||
return currentPagePath ? `/${currentPagePath}${finalEventsPath}` : finalEventsPath;
|
||||
}
|
||||
return finalEventsPath;
|
||||
});
|
||||
|
||||
async function fetchNotifications() {
|
||||
loading = true;
|
||||
try {
|
||||
const query = monitorTags.length > 0 ? `?tags=${encodeURIComponent(monitorTags.join(","))}` : "";
|
||||
const response = await fetch(clientResolver(resolve, "/dashboard-apis/notifications") + query);
|
||||
if (response.ok) {
|
||||
const payload = await response.json();
|
||||
notifications = payload.notifications || [];
|
||||
}
|
||||
notifications = await requestNotifications(monitorTags);
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
@@ -52,7 +32,7 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchNotifications();
|
||||
void fetchNotifications();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -87,40 +67,6 @@
|
||||
class="bg-background/30 supports-backdrop-filter:bg-background/20 w-96 rounded-3xl border p-0 shadow-2xl backdrop-blur-2xl"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h4 class="text-sm font-semibold">{$t("Events")}</h4>
|
||||
<Button variant="outline" href={clientResolver(resolve, resolvedEventsPath)} size="icon-sm" class="rounded-btn">
|
||||
<Calendar class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if notifications.length === 0}
|
||||
<div class="text-muted-foreground px-4 py-6 text-sm">
|
||||
{$t("No events to show")}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="scrollbar-hidden max-h-96 overflow-y-auto">
|
||||
{#each notifications as item, i (`${item.eventType}-${item.eventURL}-${item.eventDate}-${i}`)}
|
||||
<a
|
||||
href={clientResolver(resolve, item.eventURL)}
|
||||
class="hover:bg-muted/60 block border-b px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div class="my-0.5 flex items-center justify-between gap-2 text-xs">
|
||||
<span class="text-muted-foreground text-[11px] uppercase">{$t(item.eventType)}</span>
|
||||
<span class="text-{item.eventStatus.toLowerCase()}">{$t(item.eventStatus)}</span>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="line-clamp-2 text-sm">{item.eventTitle}</p>
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-1 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span>{$formatDate(item.eventDate, "PPp")}</span>
|
||||
|
||||
<span>•</span>
|
||||
<span>{$formatDuration(item.eventStartDateTime, item.eventEndDateTime, $t("Ongoing"))}</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<NotificationsList {monitorTags} {eventsPath} fetchOnMount={false} bind:notifications bind:loading />
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
@@ -375,7 +375,7 @@
|
||||
>
|
||||
<span class={getStatusColor(hoveredBar.data)}>{$t(GetStatusSummary(hoveredBar.data))}</span>
|
||||
<span class="text-muted-foreground">@</span>
|
||||
{$formatDate(hoveredBar.data.ts, "d MMM yyyy")}
|
||||
{$formatDate(hoveredBar.data.ts, page.data.dateAndTimeFormat.dateOnly)}
|
||||
{#if hoveredBar.data.avgLatency > 0}
|
||||
<span class="text-muted-foreground ml-1">|</span>
|
||||
<span class="ml-1">{ParseLatency(hoveredBar.data.avgLatency)}</span>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import Sun from "@lucide/svelte/icons/sun";
|
||||
import Moon from "@lucide/svelte/icons/moon";
|
||||
import Share from "@lucide/svelte/icons/share-2";
|
||||
import Rss from "@lucide/svelte/icons/rss";
|
||||
import { format } from "date-fns";
|
||||
import SubscribeMenu from "$lib/components/SubscribeMenu.svelte";
|
||||
import CopyButton from "$lib/components/CopyButton.svelte";
|
||||
@@ -26,14 +27,23 @@
|
||||
interface Props {
|
||||
monitor_tags?: string[];
|
||||
embedMonitorTag?: string;
|
||||
hideNotificationsPopover?: boolean;
|
||||
}
|
||||
|
||||
let { monitor_tags = [], embedMonitorTag = "" }: Props = $props();
|
||||
let { monitor_tags = [], embedMonitorTag = "", hideNotificationsPopover = false }: Props = $props();
|
||||
|
||||
let protocol = $state("");
|
||||
let domain = $state("");
|
||||
let shareLink = $state("");
|
||||
const eventsPath = $derived(`/events/${format(new Date(), "MMMM-yyyy")}`);
|
||||
const showNotificationsPopover = $derived(!hideNotificationsPopover);
|
||||
|
||||
const rssHref = $derived.by(() => {
|
||||
const params = page.params;
|
||||
if (params.monitor_tag) return clientResolver(resolve, `/monitors/${params.monitor_tag}/rss.xml`);
|
||||
if (params.page_path) return clientResolver(resolve, `/${params.page_path}/rss.xml`);
|
||||
return clientResolver(resolve, "/rss.xml");
|
||||
});
|
||||
|
||||
const loginDetails = $derived.by((): { label: string; url: string } | null => {
|
||||
if (!page.data?.loggedInUser) return null;
|
||||
@@ -95,6 +105,24 @@
|
||||
</ButtonGroup.Root>
|
||||
{/if}
|
||||
|
||||
{#if page.data.subMenuOptions?.showRssFeed !== false}
|
||||
<ButtonGroup.Root class="rounded-btn-grp shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
href={rssHref}
|
||||
target="_blank"
|
||||
rel="alternate"
|
||||
aria-label={$t("RSS feed")}
|
||||
title={$t("RSS feed")}
|
||||
class="bg-background/80 dark:bg-background/70 border-foreground/10 cursor-pointer rounded-full border shadow-none backdrop-blur-md"
|
||||
onclick={() => trackEvent("rss_opened", { source: "theme_plus" })}
|
||||
>
|
||||
<Rss />
|
||||
</Button>
|
||||
</ButtonGroup.Root>
|
||||
{/if}
|
||||
|
||||
<ButtonGroup.Root class="rounded-btn-grp shrink-0">
|
||||
<CopyButton
|
||||
variant="outline"
|
||||
@@ -157,7 +185,9 @@
|
||||
<TimezoneSelector />
|
||||
{/if}
|
||||
</ButtonGroup.Root>
|
||||
<NotificationsPopover {eventsPath} monitorTags={monitor_tags} compact={true} />
|
||||
{#if showNotificationsPopover}
|
||||
<NotificationsPopover {eventsPath} monitorTags={monitor_tags} compact={true} />
|
||||
{/if}
|
||||
{#if loginDetails}
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -69,6 +69,18 @@ export default {
|
||||
STATUS: "STATUS",
|
||||
LATENCY: "LATENCY",
|
||||
UPTIME: "UPTIME",
|
||||
// Special path segment addressing the home page in the v4 API; its stored
|
||||
// page_path is an empty string. See docs/adr/0004-home-page-api-token.md.
|
||||
HOME_PAGE_TOKEN: "~home",
|
||||
// Status history window (days of per-day status shown), shared by pages and
|
||||
// monitors, the manage UI, the public pages, and the v4 API
|
||||
DEFAULT_STATUS_HISTORY_DAYS_DESKTOP: 90,
|
||||
DEFAULT_STATUS_HISTORY_DAYS_MOBILE: 30,
|
||||
STATUS_HISTORY_DAYS_MIN: 1,
|
||||
STATUS_HISTORY_DAYS_MAX: 365,
|
||||
// Monitor layout styles available on status pages
|
||||
MONITOR_LAYOUT_STYLES: ["default-list", "default-grid", "compact-list", "compact-grid"],
|
||||
DEFAULT_MONITOR_LAYOUT_STYLE: "default-list",
|
||||
DOCS_URL: "https://kener.ing/docs",
|
||||
MAX_UPLOAD_BYTES: 2 * 1024 * 1024, // 2MB
|
||||
MAX_IMAGE_DIMENSION: 4096,
|
||||
|
||||
+31
-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 Updates": "Aktualizace incidentů",
|
||||
"Incidents": "Incidenty",
|
||||
"Included Monitors": "Monitorů zahrnuto",
|
||||
"incident": "Incident",
|
||||
"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í...",
|
||||
"Loading your preferences...": "Načítání nastavení...",
|
||||
"maintenance": "údržba",
|
||||
"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,58 +82,60 @@
|
||||
"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",
|
||||
"Recent Incidents": "Nedávné incidenty",
|
||||
"Recurring": "Opakující se",
|
||||
"RESOLVED": "VYŘEŠENO",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "NAPLÁNOVÁNO",
|
||||
"Scheduled Events (%count)": "Plánované události (%count)",
|
||||
"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 +153,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"
|
||||
}
|
||||
}
|
||||
|
||||
+35
-32
@@ -5,12 +5,12 @@
|
||||
"Affected Monitors (%count)": "Betroffene Monitore (%count)",
|
||||
"All Systems Operational": "Alle Systeme betriebsbereit",
|
||||
"Average Latency": "Durchschnittliche Latenz",
|
||||
"Avg Latency": "Durchschnittliche Latenz",
|
||||
"Avg Latency": "Durchschn. Latenz",
|
||||
"Back": "Zurück",
|
||||
"Badges": "Abzeichen",
|
||||
"Badges": "Anzeigen",
|
||||
"CANCELLED": "ABGESAGT",
|
||||
"COMPLETED": "ABGESCHLOSSEN",
|
||||
"Continue": "Weitermachen",
|
||||
"Continue": "Fortsetzen",
|
||||
"Copied": "Kopiert",
|
||||
"Current": "Aktuell",
|
||||
"Dark": "Dunkel",
|
||||
@@ -18,32 +18,35 @@
|
||||
"Day Uptime": "Tagesverfügbarkeit",
|
||||
"Days": "Tage",
|
||||
"Degraded": "Beeinträchtigt",
|
||||
"DEGRADED": "BEEINTRÄCHTIGT",
|
||||
"Degraded Performance": "Beeinträchtigte Leistung",
|
||||
"Didn't receive the code? Resend": "Sie haben den Code nicht erhalten? ",
|
||||
"Didn't receive the code? Resend": "Sie haben den Code nicht erhalten? Erneut senden",
|
||||
"Down": "Ausgefallen",
|
||||
"DOWN": "AUSGEFALLEN",
|
||||
"Duration": "Dauer",
|
||||
"Email address": "E-Mail-Adresse",
|
||||
"Embed Monitor": "Monitor einbetten",
|
||||
"Embed this monitor in your website or app": "Betten Sie diesen Monitor in Ihre Website oder App ein",
|
||||
"End Time": "Endzeit",
|
||||
"Enter the verification code sent to your email.": "Geben Sie den Bestätigungscode ein, der an Ihre E-Mail-Adresse gesendet wurde.",
|
||||
"Events": "Veranstaltungen",
|
||||
"Events": "Ereignisse",
|
||||
"Failed to load data": "Daten konnten nicht geladen werden",
|
||||
"Failed to load latency data": "Latenzdaten konnten nicht geladen werden",
|
||||
"Failed to load status data for this day": "Statusdaten für diesen Tag konnten nicht geladen werden",
|
||||
"Failed to send verification code": "Der Bestätigungscode konnte nicht gesendet werden",
|
||||
"Failed to update preference": "Die Präferenz konnte nicht aktualisiert werden",
|
||||
"Get badges for this monitor": "Erhalten Sie Abzeichen für diesen Monitor",
|
||||
"Get badges for this monitor": "Erhalten Sie Statusanzeigen für diesen Monitor",
|
||||
"Get notified about incidents and scheduled maintenance.": "Lassen Sie sich über Vorfälle und geplante Wartungsarbeiten benachrichtigen.",
|
||||
"Get notified about incidents updates": "Lassen Sie sich über Aktualisierungen von Vorfällen benachrichtigen",
|
||||
"Get notified about scheduled maintenance": "Lassen Sie sich über geplante Wartungsarbeiten benachrichtigen",
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"IDENTIFIED": "IDENTIFIZIERT",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Auswirkungen",
|
||||
"Incident Updates": "Vorfallaktualisierungen",
|
||||
"Incidents": "Vorfälle",
|
||||
"Included Monitors": "Enthaltene Monitore",
|
||||
"incident": "Vorfall",
|
||||
"Incident Updates": "Vorfallsaktualisierungen",
|
||||
"Incidents": "Vorfälle",
|
||||
"Included Monitors (%count)": "Enthaltene Monitore (%count)",
|
||||
"INVESTIGATING": "WIRD UNTERSUCHT",
|
||||
"Last Updated": "Zuletzt aktualisiert",
|
||||
"Latency": "Latenz",
|
||||
"Latency Embed": "Latenz-Einbettung",
|
||||
@@ -54,19 +57,20 @@
|
||||
"Light": "Hell",
|
||||
"Live Status": "Live-Status",
|
||||
"Loading your preferences...": "Deine Einstellungen werden geladen...",
|
||||
"maintenance": "Wartung",
|
||||
"MAINTENANCE": "WARTUNG",
|
||||
"Maintenance Updates": "Wartungsaktualisierungen",
|
||||
"Maintenances": "Wartungen",
|
||||
"maintenance": "Wartung",
|
||||
"Major System Outage": "Schwerwiegender Systemausfall",
|
||||
"Manage Site": "Seite verwalten",
|
||||
"Manage your notification preferences.": "Verwalten Sie Ihre Benachrichtigungseinstellungen.",
|
||||
"Max Latency": "Maximale Latenz",
|
||||
"Max Latency": "Max. Latenz",
|
||||
"Maximum Latency": "Maximale Latenz",
|
||||
"Min Latency": "Min. Latenz",
|
||||
"Minimum Latency": "Min. Latenz",
|
||||
"Minimum Latency": "Minimale Latenz",
|
||||
"Minute-by-minute status data for this day": "Minutenweise Statusdaten für diesen Tag",
|
||||
"MONITORING": "MONITORING",
|
||||
"Network error. Please try again.": "Netzwerkfehler. ",
|
||||
"MONITORING": "WIRD ÜBERWACHT",
|
||||
"Network error. Please try again.": "Netzwerkfehler. Bitte erneut versuchen.",
|
||||
"No Events in %currentMonth": "Keine Ereignisse in %currentMonth",
|
||||
"No events to show": "Keine Ereignisse zum Anzeigen",
|
||||
"No incidents for this day": "Keine Vorfälle für diesen Tag",
|
||||
@@ -79,7 +83,7 @@
|
||||
"No Status Available": "Kein Status verfügbar",
|
||||
"No upcoming maintenances": "Keine bevorstehenden Wartungsarbeiten",
|
||||
"No Updates": "Keine Aktualisierungen",
|
||||
"No updates yet": "Noch keine Updates",
|
||||
"No updates yet": "Noch keine Aktualisierungen",
|
||||
"Notifications": "Benachrichtigungen",
|
||||
"One-time": "Einmalig",
|
||||
"Ongoing": "Laufend",
|
||||
@@ -95,41 +99,40 @@
|
||||
"Read more": "Mehr lesen",
|
||||
"READY": "BEREIT",
|
||||
"Recurring": "Wiederkehrend",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RESOLVED": "BEHOBEN",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "GEPLANT",
|
||||
"Scheduled Events (%count)": "Geplante Ereignisse (%count)",
|
||||
"Script": "Skript",
|
||||
"Select Language": "Wählen Sie Sprache aus",
|
||||
"Select latency metric to display": "Latenzmetrik zur Anzeige auswählen",
|
||||
"Select Range": "Wählen Sie Bereich aus",
|
||||
"Select Language": "Sprache auswählen",
|
||||
"Select latency metric to display": "Anzuzeigende Latenzmetrik auswählen",
|
||||
"Select Range": "Bereich auswählen",
|
||||
"Sending...": "Senden...",
|
||||
"Standard": "Standard",
|
||||
"Start Time": "Startzeit",
|
||||
"Status": "Status",
|
||||
"Status Badge": "Statusabzeichen",
|
||||
"Status Badge": "Statusanzeige",
|
||||
"Status Embed": "Status einbetten",
|
||||
"Status history and latency trend": "Statusverlauf und Latenztrend",
|
||||
"Subscribe": "Abonnieren",
|
||||
"Subscribe to Updates": "Benachrichtigungen erhalten",
|
||||
"Subscribe to Updates": "Benachrichtigungen abonnieren",
|
||||
"There are no incidents or maintenances scheduled for this month.": "Für diesen Monat sind keine Vorfälle oder Wartungsarbeiten geplant.",
|
||||
"There are no ongoing incidents or maintenance events.": "Es gibt keine laufenden Vorfälle oder Wartungsereignisse.",
|
||||
"There are no ongoing incidents or maintenance events.": "Es gibt keine laufenden Vorfälle oder Wartungsarbeiten.",
|
||||
"Total Incidents": "Gesamtzahl der Vorfälle",
|
||||
"Total Maintenances": "Gesamtwartungen",
|
||||
"Total Maintenances": "Gesamtzahl der Wartungen",
|
||||
"Under Maintenance": "Unter Wartung",
|
||||
"Unknown impact": "Unbekannte Auswirkung",
|
||||
"Upcoming": "Demnächst",
|
||||
"UP": "AKTIV",
|
||||
"Upcoming": "Anstehend",
|
||||
"Update Incident": "Vorfall aktualisieren",
|
||||
"Update Maintenance": "Wartung aktualisieren",
|
||||
"Updates": "Aktualisierungen",
|
||||
"Updates (%count)": "Aktualisierungen (%count)",
|
||||
"Uptime": "Betriebszeit",
|
||||
"Uptime Badge": "Verfügbarkeitsabzeichen",
|
||||
"Verification failed": "Die Überprüfung ist fehlgeschlagen",
|
||||
"Uptime Badge": "Verfügbarkeitsanzeige",
|
||||
"Verification failed": "Überprüfung fehlgeschlagen",
|
||||
"Verify": "Verifizieren",
|
||||
"Verifying": "Verifizieren",
|
||||
"We sent a 6-digit code to": "Wir haben einen 6-stelligen Code an gesendet",
|
||||
"INVESTIGATING": "WIRD UNTERSUCHT",
|
||||
"IDENTIFIED": "IDENTIFIZIERT",
|
||||
"MONITORING": "WIRD ÜBERWACHT",
|
||||
"RESOLVED": "BEHOBEN"
|
||||
"We sent a 6-digit code to": "Wir haben einen 6-stelligen Code gesendet an"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
"Day Uptime": "Dag oppetid",
|
||||
"Days": "dage",
|
||||
"Degraded": "Forringet",
|
||||
"DEGRADED": "FORRINGET",
|
||||
"Degraded Performance": "Nedsat ydeevne",
|
||||
"Didn't receive the code? Resend": "Modtog du ikke koden? ",
|
||||
"Down": "Nede",
|
||||
"DOWN": "NEDE",
|
||||
"Duration": "Varighed",
|
||||
"Email address": "E-mailadresse",
|
||||
"Embed Monitor": "Integrer skærm",
|
||||
@@ -40,10 +42,10 @@
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Indvirkning",
|
||||
"incident": "Hændelse",
|
||||
"Incident Updates": "Hændelsesopdateringer",
|
||||
"Incidents": "Hændelser",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"incident": "Hændelse",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Sidst opdateret",
|
||||
"Latency": "Latency",
|
||||
@@ -55,9 +57,10 @@
|
||||
"Light": "Lys",
|
||||
"Live Status": "Live status",
|
||||
"Loading your preferences...": "Indlæser dine præferencer...",
|
||||
"maintenance": "Vedligeholdelse",
|
||||
"MAINTENANCE": "VEDLIGEHOLDELSE",
|
||||
"Maintenance Updates": "Vedligeholdelsesopdateringer",
|
||||
"Maintenances": "Vedligeholdelse",
|
||||
"maintenance": "Vedligeholdelse",
|
||||
"Major System Outage": "Større systemnedbrud",
|
||||
"Manage Site": "Administrer side",
|
||||
"Manage your notification preferences.": "Administrer dine meddelelsespræferencer.",
|
||||
@@ -97,6 +100,7 @@
|
||||
"READY": "KLAR",
|
||||
"Recurring": "Tilbagevendende",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "PLANLAGT",
|
||||
"Scheduled Events (%count)": "Planlagte begivenheder (%count)",
|
||||
"Script": "Manuskript",
|
||||
@@ -118,7 +122,9 @@
|
||||
"Total Maintenances": "Samlet vedligeholdelse",
|
||||
"Under Maintenance": "Under Vedligeholdelse",
|
||||
"Unknown impact": "Ukendt påvirkning",
|
||||
"UP": "OPPE",
|
||||
"Upcoming": "Kommende",
|
||||
"Update Incident": "Opdater hændelse",
|
||||
"Update Maintenance": "Opdater vedligeholdelse",
|
||||
"Updates": "Opdateringer",
|
||||
"Updates (%count)": "Opdateringer (%count)",
|
||||
|
||||
+10
-3
@@ -18,9 +18,11 @@
|
||||
"Day Uptime": "Day Uptime",
|
||||
"Days": "Days",
|
||||
"Degraded": "Degraded",
|
||||
"DEGRADED": "DEGRADED",
|
||||
"Degraded Performance": "Degraded Performance",
|
||||
"Didn't receive the code? Resend": "Didn't receive the code? Resend",
|
||||
"Down": "Down",
|
||||
"DOWN": "DOWN",
|
||||
"Duration": "Duration",
|
||||
"Email address": "Email address",
|
||||
"Embed Monitor": "Embed Monitor",
|
||||
@@ -40,10 +42,10 @@
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Impact",
|
||||
"incident": "Incident",
|
||||
"Incident Updates": "Incident Updates",
|
||||
"Incidents": "Incidents",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"incident": "Incident",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Last Updated",
|
||||
"Latency": "Latency",
|
||||
@@ -55,9 +57,10 @@
|
||||
"Light": "Light",
|
||||
"Live Status": "Live Status",
|
||||
"Loading your preferences...": "Loading your preferences...",
|
||||
"maintenance": "Maintenance",
|
||||
"MAINTENANCE": "MAINTENANCE",
|
||||
"Maintenance Updates": "Maintenance Updates",
|
||||
"Maintenances": "Maintenances",
|
||||
"maintenance": "Maintenance",
|
||||
"Major System Outage": "Major System Outage",
|
||||
"Manage Site": "Manage Site",
|
||||
"Manage your notification preferences.": "Manage your notification preferences.",
|
||||
@@ -84,6 +87,7 @@
|
||||
"Notifications": "Notifications",
|
||||
"One-time": "One-time",
|
||||
"Ongoing": "Ongoing",
|
||||
"Open events page": "Open events page",
|
||||
"Operational": "Operational",
|
||||
"Partial Degraded Performance": "Partial Degraded Performance",
|
||||
"Partial System Outage": "Partial System Outage",
|
||||
@@ -97,6 +101,7 @@
|
||||
"READY": "READY",
|
||||
"Recurring": "Recurring",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "SCHEDULED",
|
||||
"Scheduled Events (%count)": "Scheduled Events (%count)",
|
||||
"Script": "Script",
|
||||
@@ -118,7 +123,9 @@
|
||||
"Total Maintenances": "Total Maintenances",
|
||||
"Under Maintenance": "Under Maintenance",
|
||||
"Unknown impact": "Unknown impact",
|
||||
"UP": "UP",
|
||||
"Upcoming": "Upcoming",
|
||||
"Update Incident": "Update Incident",
|
||||
"Update Maintenance": "Update Maintenance",
|
||||
"Updates": "Updates",
|
||||
"Updates (%count)": "Updates (%count)",
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
"Day Uptime": "Tiempo de actividad del día",
|
||||
"Days": "Días",
|
||||
"Degraded": "Degradado",
|
||||
"DEGRADED": "DEGRADADO",
|
||||
"Degraded Performance": "Rendimiento degradado",
|
||||
"Didn't receive the code? Resend": "¿No recibiste el código? ",
|
||||
"Down": "Caído",
|
||||
"DOWN": "CAÍDO",
|
||||
"Duration": "Duración",
|
||||
"Email address": "Dirección de correo electrónico",
|
||||
"Embed Monitor": "Monitor integrado",
|
||||
@@ -40,10 +42,10 @@
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "marco flotante",
|
||||
"Impact": "Impacto",
|
||||
"incident": "Incidente",
|
||||
"Incident Updates": "Actualizaciones de incidentes",
|
||||
"Incidents": "Incidentes",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"incident": "Incidente",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Última actualización",
|
||||
"Latency": "Estado latente",
|
||||
@@ -55,9 +57,10 @@
|
||||
"Light": "Luz",
|
||||
"Live Status": "Estado en vivo",
|
||||
"Loading your preferences...": "Cargando tus preferencias...",
|
||||
"maintenance": "Mantenimiento",
|
||||
"MAINTENANCE": "MANTENIMIENTO",
|
||||
"Maintenance Updates": "Actualizaciones de mantenimiento",
|
||||
"Maintenances": "Mantenimientos",
|
||||
"maintenance": "Mantenimiento",
|
||||
"Major System Outage": "Interrupción importante del sistema",
|
||||
"Manage Site": "Gestionar sitio",
|
||||
"Manage your notification preferences.": "Administre sus preferencias de notificación.",
|
||||
@@ -97,6 +100,7 @@
|
||||
"READY": "LISTO",
|
||||
"Recurring": "Recurrente",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "PROGRAMADO",
|
||||
"Scheduled Events (%count)": "Eventos programados (%count)",
|
||||
"Script": "Guion",
|
||||
@@ -118,7 +122,9 @@
|
||||
"Total Maintenances": "Mantenimientos totales",
|
||||
"Under Maintenance": "En mantenimiento",
|
||||
"Unknown impact": "Impacto desconocido",
|
||||
"UP": "ACTIVO",
|
||||
"Upcoming": "Próximo",
|
||||
"Update Incident": "Actualizar incidente",
|
||||
"Update Maintenance": "Actualizar mantenimiento",
|
||||
"Updates": "Actualizaciones",
|
||||
"Updates (%count)": "Actualizaciones (%count)",
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
"Day Uptime": "تایم روز",
|
||||
"Days": "روزها",
|
||||
"Degraded": "کاهش یافته",
|
||||
"DEGRADED": "کاهش یافته",
|
||||
"Degraded Performance": "عملکرد کاهشیافته",
|
||||
"Didn't receive the code? Resend": "کد را دریافت نکردید؟ ",
|
||||
"Down": "از کار افتاده",
|
||||
"DOWN": "قطع",
|
||||
"Duration": "مدت",
|
||||
"Email address": "آدرس ایمیل",
|
||||
"Embed Monitor": "تعبیه مانیتور",
|
||||
@@ -40,10 +42,10 @@
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "آی فریم",
|
||||
"Impact": "تاثیر",
|
||||
"incident": "رخداد",
|
||||
"Incident Updates": "به روز رسانی حادثه",
|
||||
"Incidents": "حوادث",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"incident": "رخداد",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "آخرین به روز رسانی",
|
||||
"Latency": "تأخیر",
|
||||
@@ -55,9 +57,10 @@
|
||||
"Light": "نور",
|
||||
"Live Status": "وضعیت زنده",
|
||||
"Loading your preferences...": "در حال بارگیری تنظیمات برگزیده شما...",
|
||||
"maintenance": "نگهداری",
|
||||
"MAINTENANCE": "تعمیر و نگهداری",
|
||||
"Maintenance Updates": "به روز رسانی های تعمیر و نگهداری",
|
||||
"Maintenances": "تعمیر و نگهداری",
|
||||
"maintenance": "نگهداری",
|
||||
"Major System Outage": "قطعی عمده سیستم",
|
||||
"Manage Site": "مدیریت سایت",
|
||||
"Manage your notification preferences.": "تنظیمات برگزیده اعلان خود را مدیریت کنید.",
|
||||
@@ -97,6 +100,7 @@
|
||||
"READY": "آماده",
|
||||
"Recurring": "دورهای",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "زمانبندی شده",
|
||||
"Scheduled Events (%count)": "رویدادهای زمانبندی شده (%count)",
|
||||
"Script": "اسکریپت",
|
||||
@@ -118,7 +122,9 @@
|
||||
"Total Maintenances": "کل تعمیر و نگهداری",
|
||||
"Under Maintenance": "تحت تعمیر و نگهداری",
|
||||
"Unknown impact": "تاثیر نامعلوم",
|
||||
"UP": "فعال",
|
||||
"Upcoming": "آینده",
|
||||
"Update Incident": "بهروزرسانی رویداد",
|
||||
"Update Maintenance": "بهروزرسانی نگهداری",
|
||||
"Updates": "بهروزرسانیها",
|
||||
"Updates (%count)": "بهروزرسانیها (%count)",
|
||||
|
||||
+13
-7
@@ -3,11 +3,11 @@
|
||||
"mappings": {
|
||||
"%latency %metric latency": "%latency %metric latence",
|
||||
"Affected Monitors (%count)": "Moniteurs concernés (%count)",
|
||||
"All Systems Operational": "Tous les systèmes opérationnels",
|
||||
"All Systems Operational": "Tous les systèmes sont opérationnels",
|
||||
"Average Latency": "Latence moyenne",
|
||||
"Avg Latency": "Latence moyenne",
|
||||
"Back": "Dos",
|
||||
"Badges": "Insignes",
|
||||
"Back": "Retour",
|
||||
"Badges": "Badges",
|
||||
"CANCELLED": "ANNULÉ",
|
||||
"COMPLETED": "TERMINÉ",
|
||||
"Continue": "Continuer",
|
||||
@@ -18,9 +18,11 @@
|
||||
"Day Uptime": "Disponibilité journalière",
|
||||
"Days": "Jours",
|
||||
"Degraded": "Dégradé",
|
||||
"DEGRADED": "DÉGRADÉ",
|
||||
"Degraded Performance": "Performance dégradée",
|
||||
"Didn't receive the code? Resend": "Vous n'avez pas reçu le code ? ",
|
||||
"Down": "Hors service",
|
||||
"DOWN": "EN PANNE",
|
||||
"Duration": "Durée",
|
||||
"Email address": "Adresse email",
|
||||
"Embed Monitor": "Intégrer le moniteur",
|
||||
@@ -40,10 +42,10 @@
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Impact",
|
||||
"incident": "Incident",
|
||||
"Incident Updates": "Mises à jour des incidents",
|
||||
"Incidents": "Incidents",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"incident": "Incident",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Dernière mise à jour",
|
||||
"Latency": "Latence",
|
||||
@@ -55,9 +57,10 @@
|
||||
"Light": "Lumière",
|
||||
"Live Status": "Statut en direct",
|
||||
"Loading your preferences...": "Chargement de vos préférences...",
|
||||
"maintenance": "Maintenance",
|
||||
"MAINTENANCE": "MAINTENANCE",
|
||||
"Maintenance Updates": "Mises à jour de maintenance",
|
||||
"Maintenances": "Entretiens",
|
||||
"maintenance": "Maintenance",
|
||||
"Major System Outage": "Panne majeure du système",
|
||||
"Manage Site": "Gérer le site",
|
||||
"Manage your notification preferences.": "Gérez vos préférences de notification.",
|
||||
@@ -90,13 +93,14 @@
|
||||
"Past": "Passé",
|
||||
"Per-Minute Status": "Statut par minute",
|
||||
"Pinging": "Ping",
|
||||
"Please enter a valid email address": "S'il vous plaît, mettez une adresse email valide",
|
||||
"Please enter a valid email address": "Veuillez renseigner une adresse email valide",
|
||||
"Please enter the 6-digit verification code": "Veuillez saisir le code de vérification à 6 chiffres",
|
||||
"Read less": "Lire moins",
|
||||
"Read more": "En savoir plus",
|
||||
"READY": "PRÊT",
|
||||
"Recurring": "Récurrent",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "PLANIFIÉ",
|
||||
"Scheduled Events (%count)": "Événements planifiés (%count)",
|
||||
"Script": "Scénario",
|
||||
@@ -118,7 +122,9 @@
|
||||
"Total Maintenances": "Entretiens totaux",
|
||||
"Under Maintenance": "En maintenance",
|
||||
"Unknown impact": "Impact inconnu",
|
||||
"UP": "OPÉRATIONNEL",
|
||||
"Upcoming": "Prochain",
|
||||
"Update Incident": "Mettre à jour l'incident",
|
||||
"Update Maintenance": "Mettre à jour la maintenance",
|
||||
"Updates": "Mises à jour",
|
||||
"Updates (%count)": "Mises à jour (%count)",
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
"Day Uptime": "दिन का अपटाइम",
|
||||
"Days": "दिन",
|
||||
"Degraded": "अवनत",
|
||||
"DEGRADED": "अवक्रमित",
|
||||
"Degraded Performance": "प्रदर्शन में गिरावट",
|
||||
"Didn't receive the code? Resend": "कोड नहीं मिला? फिर से भेजें",
|
||||
"Down": "बंद",
|
||||
"DOWN": "बंद",
|
||||
"Duration": "अवधि",
|
||||
"Email address": "ईमेल पता",
|
||||
"Embed Monitor": "मॉनिटर एम्बेड करें",
|
||||
@@ -40,10 +42,10 @@
|
||||
"IDENTIFIED": "पहचाना गया",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "प्रभाव",
|
||||
"incident": "घटना",
|
||||
"Incident Updates": "घटना अपडेट",
|
||||
"Incidents": "घटनाएँ",
|
||||
"Included Monitors": "शामिल मॉनिटर",
|
||||
"incident": "घटना",
|
||||
"Included Monitors (%count)": "शामिल मॉनिटर (%count)",
|
||||
"INVESTIGATING": "जाँच कर रहे हैं",
|
||||
"Last Updated": "अंतिम अपडेट",
|
||||
"Latency": "लेटेंसी",
|
||||
@@ -55,9 +57,10 @@
|
||||
"Light": "लाइट",
|
||||
"Live Status": "लाइव स्थिति",
|
||||
"Loading your preferences...": "आपकी प्राथमिकताएँ लोड हो रही हैं...",
|
||||
"maintenance": "रखरखाव",
|
||||
"MAINTENANCE": "रखरखाव",
|
||||
"Maintenance Updates": "रखरखाव अपडेट",
|
||||
"Maintenances": "रखरखाव",
|
||||
"maintenance": "रखरखाव",
|
||||
"Major System Outage": "सिस्टम का बड़ा आउटेज",
|
||||
"Manage Site": "साइट प्रबंधित करें",
|
||||
"Manage your notification preferences.": "अपनी सूचना प्राथमिकताएँ प्रबंधित करें।",
|
||||
@@ -97,6 +100,7 @@
|
||||
"READY": "तैयार",
|
||||
"Recurring": "आवर्ती",
|
||||
"RESOLVED": "सुलझा हुआ",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "अनुसूचित",
|
||||
"Scheduled Events (%count)": "अनुसूचित कार्यक्रम (%count)",
|
||||
"Script": "स्क्रिप्ट",
|
||||
@@ -118,7 +122,9 @@
|
||||
"Total Maintenances": "कुल रखरखाव",
|
||||
"Under Maintenance": "रखरखाव जारी है",
|
||||
"Unknown impact": "अज्ञात प्रभाव",
|
||||
"UP": "चालू",
|
||||
"Upcoming": "आने वाला",
|
||||
"Update Incident": "घटना अपडेट करें",
|
||||
"Update Maintenance": "रखरखाव अपडेट करें",
|
||||
"Updates": "अपडेट्स",
|
||||
"Updates (%count)": "अपडेट (%count)",
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
"Day Uptime": "Tempo di attività giornaliero",
|
||||
"Days": "Giorni",
|
||||
"Degraded": "Degradato",
|
||||
"DEGRADED": "DEGRADATO",
|
||||
"Degraded Performance": "Prestazioni degradate",
|
||||
"Didn't receive the code? Resend": "Non hai ricevuto il codice? ",
|
||||
"Down": "Non disponibile",
|
||||
"DOWN": "NON DISPONIBILE",
|
||||
"Duration": "Durata",
|
||||
"Email address": "Indirizzo e-mail",
|
||||
"Embed Monitor": "Incorpora monitoraggio",
|
||||
@@ -40,10 +42,10 @@
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Impatto",
|
||||
"incident": "Incidente",
|
||||
"Incident Updates": "Aggiornamenti sugli incidenti",
|
||||
"Incidents": "Incidenti",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"incident": "Incidente",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Ultimo aggiornamento",
|
||||
"Latency": "Latenza",
|
||||
@@ -55,9 +57,10 @@
|
||||
"Light": "Leggero",
|
||||
"Live Status": "Stato in tempo reale",
|
||||
"Loading your preferences...": "Caricamento delle tue preferenze...",
|
||||
"maintenance": "Manutenzione",
|
||||
"MAINTENANCE": "MANUTENZIONE",
|
||||
"Maintenance Updates": "Aggiornamenti sulla manutenzione",
|
||||
"Maintenances": "Manutenzioni",
|
||||
"maintenance": "Manutenzione",
|
||||
"Major System Outage": "Interruzione grave del sistema",
|
||||
"Manage Site": "Gestisci sito",
|
||||
"Manage your notification preferences.": "Gestisci le tue preferenze di notifica.",
|
||||
@@ -97,6 +100,7 @@
|
||||
"READY": "PRONTO",
|
||||
"Recurring": "Ricorrente",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "PROGRAMMATO",
|
||||
"Scheduled Events (%count)": "Eventi programmati (%count)",
|
||||
"Script": "Copione",
|
||||
@@ -118,7 +122,9 @@
|
||||
"Total Maintenances": "Manutenzioni totali",
|
||||
"Under Maintenance": "In manutenzione",
|
||||
"Unknown impact": "Impatto sconosciuto",
|
||||
"UP": "ATTIVO",
|
||||
"Upcoming": "Prossimamente",
|
||||
"Update Incident": "Aggiorna incidente",
|
||||
"Update Maintenance": "Aggiorna manutenzione",
|
||||
"Updates": "Aggiornamenti",
|
||||
"Updates (%count)": "Aggiornamenti (%count)",
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
"Day Uptime": "一日の稼働時間",
|
||||
"Days": "日数",
|
||||
"Degraded": "低下",
|
||||
"DEGRADED": "劣化",
|
||||
"Degraded Performance": "パフォーマンスの低下",
|
||||
"Didn't receive the code? Resend": "コードを受け取っていませんか?",
|
||||
"Down": "停止",
|
||||
"DOWN": "障害",
|
||||
"Duration": "間隔",
|
||||
"Email address": "電子メールアドレス",
|
||||
"Embed Monitor": "埋め込みモニター",
|
||||
@@ -40,10 +42,10 @@
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "インパクト",
|
||||
"incident": "インシデント",
|
||||
"Incident Updates": "インシデントの最新情報",
|
||||
"Incidents": "事件",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"incident": "インシデント",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "最終更新日",
|
||||
"Latency": "レイテンシー",
|
||||
@@ -55,9 +57,10 @@
|
||||
"Light": "ライト",
|
||||
"Live Status": "ライブステータス",
|
||||
"Loading your preferences...": "設定を読み込んでいます...",
|
||||
"maintenance": "メンテナンス",
|
||||
"MAINTENANCE": "メンテナンス",
|
||||
"Maintenance Updates": "メンテナンスアップデート",
|
||||
"Maintenances": "メンテナンス",
|
||||
"maintenance": "メンテナンス",
|
||||
"Major System Outage": "大規模なシステム障害",
|
||||
"Manage Site": "サイト管理",
|
||||
"Manage your notification preferences.": "通知設定を管理します。",
|
||||
@@ -97,6 +100,7 @@
|
||||
"READY": "準備完了",
|
||||
"Recurring": "繰り返し",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "予定済み",
|
||||
"Scheduled Events (%count)": "予定イベント (%count)",
|
||||
"Script": "スクリプト",
|
||||
@@ -118,7 +122,9 @@
|
||||
"Total Maintenances": "トータルメンテナンス",
|
||||
"Under Maintenance": "メンテナンス中",
|
||||
"Unknown impact": "未知の影響",
|
||||
"UP": "正常",
|
||||
"Upcoming": "今後の予定",
|
||||
"Update Incident": "インシデントを更新",
|
||||
"Update Maintenance": "メンテナンスを更新",
|
||||
"Updates": "更新",
|
||||
"Updates (%count)": "アップデート (%count)",
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
"Day Uptime": "일일 가동 시간",
|
||||
"Days": "날",
|
||||
"Degraded": "저하",
|
||||
"DEGRADED": "성능 저하",
|
||||
"Degraded Performance": "성능 저하",
|
||||
"Didn't receive the code? Resend": "코드를 받지 못하셨나요? 재전송",
|
||||
"Down": "중단",
|
||||
"DOWN": "장애",
|
||||
"Duration": "지속",
|
||||
"Email address": "이메일 주소",
|
||||
"Embed Monitor": "모니터 내장",
|
||||
@@ -40,10 +42,10 @@
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "아이프레임",
|
||||
"Impact": "영향",
|
||||
"incident": "인시던트",
|
||||
"Incident Updates": "사고 업데이트",
|
||||
"Incidents": "사건",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"incident": "인시던트",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "마지막 업데이트",
|
||||
"Latency": "숨어 있음",
|
||||
@@ -55,9 +57,10 @@
|
||||
"Light": "빛",
|
||||
"Live Status": "실시간 현황",
|
||||
"Loading your preferences...": "환경설정 로드 중...",
|
||||
"maintenance": "유지보수",
|
||||
"MAINTENANCE": "점검 중",
|
||||
"Maintenance Updates": "유지보수 업데이트",
|
||||
"Maintenances": "유지보수",
|
||||
"maintenance": "유지보수",
|
||||
"Major System Outage": "대규모 시스템 장애",
|
||||
"Manage Site": "사이트 관리",
|
||||
"Manage your notification preferences.": "알림 기본 설정을 관리하세요.",
|
||||
@@ -97,6 +100,7 @@
|
||||
"READY": "준비됨",
|
||||
"Recurring": "반복",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "예정됨",
|
||||
"Scheduled Events (%count)": "예정된 이벤트 (%count)",
|
||||
"Script": "스크립트",
|
||||
@@ -118,7 +122,9 @@
|
||||
"Total Maintenances": "총 유지보수",
|
||||
"Under Maintenance": "유지보수 중",
|
||||
"Unknown impact": "알 수 없는 영향",
|
||||
"UP": "정상",
|
||||
"Upcoming": "예정",
|
||||
"Update Incident": "인시던트 업데이트",
|
||||
"Update Maintenance": "유지보수 업데이트",
|
||||
"Updates": "업데이트",
|
||||
"Updates (%count)": "업데이트(%count)",
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
"Day Uptime": "Dag oppetid",
|
||||
"Days": "Dager",
|
||||
"Degraded": "Degradert",
|
||||
"DEGRADED": "FORRINGET",
|
||||
"Degraded Performance": "Redusert ytelse",
|
||||
"Didn't receive the code? Resend": "Fikk du ikke koden? Send på nytt",
|
||||
"Down": "Nede",
|
||||
"DOWN": "NEDE",
|
||||
"Duration": "Varighet",
|
||||
"Email address": "E-postadresse",
|
||||
"Embed Monitor": "Bygg inn monitor",
|
||||
@@ -40,10 +42,10 @@
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Påvirkning",
|
||||
"incident": "Hendelse",
|
||||
"Incident Updates": "Hendelsesoppdateringer",
|
||||
"Incidents": "Hendelser",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"incident": "Hendelse",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Sist oppdatert",
|
||||
"Latency": "Latens",
|
||||
@@ -55,9 +57,10 @@
|
||||
"Light": "Lys",
|
||||
"Live Status": "Live-status",
|
||||
"Loading your preferences...": "Laster inn innstillingene dine ...",
|
||||
"maintenance": "Vedlikehold",
|
||||
"MAINTENANCE": "VEDLIKEHOLD",
|
||||
"Maintenance Updates": "Vedlikeholdsoppdateringer",
|
||||
"Maintenances": "Vedlikehold",
|
||||
"maintenance": "Vedlikehold",
|
||||
"Major System Outage": "Større systemutfall",
|
||||
"Manage Site": "Administrer nettsted",
|
||||
"Manage your notification preferences.": "Administrer varslingspreferansene dine.",
|
||||
@@ -97,6 +100,7 @@
|
||||
"READY": "KLAR",
|
||||
"Recurring": "Gjentakende",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "PLANLAGT",
|
||||
"Scheduled Events (%count)": "Planlagte hendelser (%count)",
|
||||
"Script": "Manus",
|
||||
@@ -118,7 +122,9 @@
|
||||
"Total Maintenances": "Totalt vedlikehold",
|
||||
"Under Maintenance": "Under Vedlikehold",
|
||||
"Unknown impact": "Ukjent påvirkning",
|
||||
"UP": "OPPE",
|
||||
"Upcoming": "Kommende",
|
||||
"Update Incident": "Oppdater hendelse",
|
||||
"Update Maintenance": "Oppdater vedlikehold",
|
||||
"Updates": "Oppdateringer",
|
||||
"Updates (%count)": "Oppdateringer (%count)",
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
"Day Uptime": "Dag uptime",
|
||||
"Days": "Dagen",
|
||||
"Degraded": "Verstoord",
|
||||
"DEGRADED": "VERSLECHTERD",
|
||||
"Degraded Performance": "Verminderde prestaties",
|
||||
"Didn't receive the code? Resend": "Heb je de code niet ontvangen? ",
|
||||
"Down": "Niet bereikbaar",
|
||||
"DOWN": "OFFLINE",
|
||||
"Duration": "Duur",
|
||||
"Email address": "E-mailadres",
|
||||
"Embed Monitor": "Monitor insluiten",
|
||||
@@ -40,10 +42,10 @@
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Invloed",
|
||||
"incident": "Incident",
|
||||
"Incident Updates": "Incidentupdates",
|
||||
"Incidents": "Incidenten",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"incident": "Incident",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Laatst bijgewerkt",
|
||||
"Latency": "Latentie",
|
||||
@@ -55,9 +57,10 @@
|
||||
"Light": "Licht",
|
||||
"Live Status": "Live-status",
|
||||
"Loading your preferences...": "Uw voorkeuren laden...",
|
||||
"maintenance": "Onderhoud",
|
||||
"MAINTENANCE": "ONDERHOUD",
|
||||
"Maintenance Updates": "Onderhoudsupdates",
|
||||
"Maintenances": "Onderhouden",
|
||||
"maintenance": "Onderhoud",
|
||||
"Major System Outage": "Grote systeemstoring",
|
||||
"Manage Site": "Site beheren",
|
||||
"Manage your notification preferences.": "Beheer uw meldingsvoorkeuren.",
|
||||
@@ -97,6 +100,7 @@
|
||||
"READY": "GEREED",
|
||||
"Recurring": "Terugkerend",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "GEPLAND",
|
||||
"Scheduled Events (%count)": "Geplande evenementen (%count)",
|
||||
"Script": "Script",
|
||||
@@ -118,7 +122,9 @@
|
||||
"Total Maintenances": "Totaal onderhoud",
|
||||
"Under Maintenance": "Onder Onderhoud",
|
||||
"Unknown impact": "Onbekende impact",
|
||||
"UP": "ACTIEF",
|
||||
"Upcoming": "Aankomend",
|
||||
"Update Incident": "Incident bijwerken",
|
||||
"Update Maintenance": "Onderhoud bijwerken",
|
||||
"Updates": "Actualisaties",
|
||||
"Updates (%count)": "Updates (%count)",
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
"Day Uptime": "Dzień sprawności",
|
||||
"Days": "Dni",
|
||||
"Degraded": "Pogorszony",
|
||||
"DEGRADED": "POGORSZONY",
|
||||
"Degraded Performance": "Obniżona wydajność",
|
||||
"Didn't receive the code? Resend": "Nie otrzymałeś kodu? ",
|
||||
"Down": "Niedostępny",
|
||||
"DOWN": "AWARIA",
|
||||
"Duration": "Czas trwania",
|
||||
"Email address": "Adres e-mail",
|
||||
"Embed Monitor": "Wstaw monitor",
|
||||
@@ -40,10 +42,10 @@
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "iFramka",
|
||||
"Impact": "Uderzenie",
|
||||
"incident": "Incydent",
|
||||
"Incident Updates": "Aktualizacje incydentów",
|
||||
"Incidents": "Incydenty",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"incident": "Incydent",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Ostatnia aktualizacja",
|
||||
"Latency": "Utajenie",
|
||||
@@ -55,9 +57,10 @@
|
||||
"Light": "Światło",
|
||||
"Live Status": "Stan na żywo",
|
||||
"Loading your preferences...": "Ładowanie Twoich preferencji...",
|
||||
"maintenance": "Konserwacja",
|
||||
"MAINTENANCE": "KONSERWACJA",
|
||||
"Maintenance Updates": "Aktualizacje konserwacyjne",
|
||||
"Maintenances": "Konserwacje",
|
||||
"maintenance": "Konserwacja",
|
||||
"Major System Outage": "Poważna awaria systemu",
|
||||
"Manage Site": "Zarządzaj stroną",
|
||||
"Manage your notification preferences.": "Zarządzaj preferencjami powiadomień.",
|
||||
@@ -97,6 +100,7 @@
|
||||
"READY": "GOTOWE",
|
||||
"Recurring": "Cykliczne",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "ZAPLANOWANE",
|
||||
"Scheduled Events (%count)": "Zaplanowane wydarzenia (%count)",
|
||||
"Script": "Scenariusz",
|
||||
@@ -118,7 +122,9 @@
|
||||
"Total Maintenances": "Całkowita konserwacja",
|
||||
"Under Maintenance": "W ramach konserwacji",
|
||||
"Unknown impact": "Nieznany wpływ",
|
||||
"UP": "DZIAŁA",
|
||||
"Upcoming": "Nadchodzące",
|
||||
"Update Incident": "Aktualizuj incydent",
|
||||
"Update Maintenance": "Aktualizuj konserwację",
|
||||
"Updates": "Aktualizacje",
|
||||
"Updates (%count)": "Aktualizacje (%count)",
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
"Day Uptime": "Tempo de atividade diurno",
|
||||
"Days": "Dias",
|
||||
"Degraded": "Degradado",
|
||||
"DEGRADED": "DEGRADADO",
|
||||
"Degraded Performance": "Desempenho degradado",
|
||||
"Didn't receive the code? Resend": "Não recebeu o código? ",
|
||||
"Down": "Fora do ar",
|
||||
"DOWN": "FORA DO AR",
|
||||
"Duration": "Duração",
|
||||
"Email address": "Endereço de email",
|
||||
"Embed Monitor": "Incorporar monitor",
|
||||
@@ -40,10 +42,10 @@
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Impacto",
|
||||
"incident": "Incidente",
|
||||
"Incident Updates": "Atualizações de incidentes",
|
||||
"Incidents": "Incidentes",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"incident": "Incidente",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Última atualização",
|
||||
"Latency": "Latência",
|
||||
@@ -55,9 +57,10 @@
|
||||
"Light": "Luz",
|
||||
"Live Status": "Status ao vivo",
|
||||
"Loading your preferences...": "Carregando suas preferências...",
|
||||
"maintenance": "Manutenção",
|
||||
"MAINTENANCE": "MANUTENÇÃO",
|
||||
"Maintenance Updates": "Atualizações de manutenção",
|
||||
"Maintenances": "Manutenção",
|
||||
"maintenance": "Manutenção",
|
||||
"Major System Outage": "Indisponibilidade grave do sistema",
|
||||
"Manage Site": "Gerenciar site",
|
||||
"Manage your notification preferences.": "Gerencie suas preferências de notificação.",
|
||||
@@ -97,6 +100,7 @@
|
||||
"READY": "PRONTO",
|
||||
"Recurring": "Recorrente",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "AGENDADO",
|
||||
"Scheduled Events (%count)": "Eventos agendados (%count)",
|
||||
"Script": "Roteiro",
|
||||
@@ -118,7 +122,9 @@
|
||||
"Total Maintenances": "Manutenção total",
|
||||
"Under Maintenance": "Em manutenção",
|
||||
"Unknown impact": "Impacto desconhecido",
|
||||
"UP": "ATIVO",
|
||||
"Upcoming": "Por vir",
|
||||
"Update Incident": "Atualizar incidente",
|
||||
"Update Maintenance": "Atualizar manutenção",
|
||||
"Updates": "Atualizações",
|
||||
"Updates (%count)": "Atualizações (%count)",
|
||||
|
||||
@@ -19,9 +19,11 @@
|
||||
"Day Uptime": "Время работы за день",
|
||||
"Days": "Days",
|
||||
"Degraded": "Деградация",
|
||||
"DEGRADED": "УХУДШЕНИЕ",
|
||||
"Degraded Performance": "Снижение производительности",
|
||||
"Didn't receive the code? Resend": "Не получили код? Отправить повторно",
|
||||
"Down": "Недоступен",
|
||||
"DOWN": "НЕ РАБОТАЕТ",
|
||||
"Duration": "Продолжительность",
|
||||
"Email address": "Адрес электронной почты",
|
||||
"Embed Monitor": "Встроить монитор",
|
||||
@@ -41,10 +43,10 @@
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Влияние",
|
||||
"incident": "Инцидент",
|
||||
"Incident Updates": "Обновления инцидентов",
|
||||
"Incidents": "Инциденты",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"incident": "Инцидент",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Последнее обновление",
|
||||
"Latency": "Задержка",
|
||||
@@ -56,9 +58,10 @@
|
||||
"Light": "Светлая",
|
||||
"Live Status": "Статус в реальном времени",
|
||||
"Loading your preferences...": "Загрузка настроек...",
|
||||
"maintenance": "Обслуживание",
|
||||
"MAINTENANCE": "ОБСЛУЖИВАНИЕ",
|
||||
"Maintenance Updates": "Обновления обслуживания",
|
||||
"Maintenances": "Обслуживание",
|
||||
"maintenance": "Обслуживание",
|
||||
"Major System Outage": "Крупный сбой системы",
|
||||
"Manage Site": "Управление сайтом",
|
||||
"Manage your notification preferences.": "Управляйте настройками уведомлений.",
|
||||
@@ -98,6 +101,7 @@
|
||||
"READY": "ГОТОВО",
|
||||
"Recurring": "Повторяющийся",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "ЗАПЛАНИРОВАНО",
|
||||
"Scheduled Events (%count)": "Запланированные события (%count)",
|
||||
"Script": "Скрипт",
|
||||
@@ -119,7 +123,9 @@
|
||||
"Total Maintenances": "Total Maintenances",
|
||||
"Under Maintenance": "На обслуживании",
|
||||
"Unknown impact": "Неизвестное влияние",
|
||||
"UP": "РАБОТАЕТ",
|
||||
"Upcoming": "Предстоящие",
|
||||
"Update Incident": "Обновить инцидент",
|
||||
"Update Maintenance": "Обновить обслуживание",
|
||||
"Updates": "Обновления",
|
||||
"Updates (%count)": "Обновления (%count)",
|
||||
|
||||
+36
-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 Updates": "Aktualizácie incidentov",
|
||||
"Incidents": "Incidenty",
|
||||
"Included Monitors": "Monitorov zahrnuté",
|
||||
"incident": "Incident",
|
||||
"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...",
|
||||
"Loading your preferences...": "Načítavanie nastavení...",
|
||||
"maintenance": "údržba",
|
||||
"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",
|
||||
@@ -115,25 +116,26 @@
|
||||
"Recent Incidents": "Nedávne incidenty",
|
||||
"Recurring": "Opakujúce sa",
|
||||
"RESOLVED": "VYRIEŠENÉ",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "NAPLÁNOVANÉ",
|
||||
"Scheduled Events (%count)": "Plánované udalosti (%count)",
|
||||
"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 +153,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
"Day Uptime": "Gün Çalışma Süresi",
|
||||
"Days": "Günler",
|
||||
"Degraded": "Bozulmuş",
|
||||
"DEGRADED": "DÜŞÜK PERFORMANS",
|
||||
"Degraded Performance": "Düşük performans",
|
||||
"Didn't receive the code? Resend": "Kodu almadınız mı? ",
|
||||
"Down": "Çevrimdışı",
|
||||
"DOWN": "ÇÖKME",
|
||||
"Duration": "Süre",
|
||||
"Email address": "E-posta adresi",
|
||||
"Embed Monitor": "Gömülü Monitör",
|
||||
@@ -40,10 +42,10 @@
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Darbe",
|
||||
"incident": "Olay",
|
||||
"Incident Updates": "Olay Güncellemeleri",
|
||||
"Incidents": "Olaylar",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"incident": "Olay",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Son Güncelleme",
|
||||
"Latency": "Gecikme",
|
||||
@@ -55,9 +57,10 @@
|
||||
"Light": "Işık",
|
||||
"Live Status": "Canlı Durum",
|
||||
"Loading your preferences...": "Tercihleriniz yükleniyor...",
|
||||
"maintenance": "Bakım",
|
||||
"MAINTENANCE": "BAKIM",
|
||||
"Maintenance Updates": "Bakım Güncellemeleri",
|
||||
"Maintenances": "Bakımlar",
|
||||
"maintenance": "Bakım",
|
||||
"Major System Outage": "Büyük sistem kesintisi",
|
||||
"Manage Site": "Siteyi yönet",
|
||||
"Manage your notification preferences.": "Bildirim tercihlerinizi yönetin.",
|
||||
@@ -97,6 +100,7 @@
|
||||
"READY": "HAZIR",
|
||||
"Recurring": "Tekrarlayan",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "PLANLANMIŞ",
|
||||
"Scheduled Events (%count)": "Planlanan etkinlikler (%count)",
|
||||
"Script": "Senaryo",
|
||||
@@ -118,7 +122,9 @@
|
||||
"Total Maintenances": "Toplam Bakımlar",
|
||||
"Under Maintenance": "Bakımda",
|
||||
"Unknown impact": "Bilinmeyen etki",
|
||||
"UP": "ÇALIŞIYOR",
|
||||
"Upcoming": "Yaklaşan",
|
||||
"Update Incident": "Olayı güncelle",
|
||||
"Update Maintenance": "Bakımı güncelle",
|
||||
"Updates": "Güncellemeler",
|
||||
"Updates (%count)": "Güncellemeler (%count)",
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"name": "Українська",
|
||||
"code": "uk",
|
||||
"mappings": {
|
||||
"%latency %metric latency": "%latency %metric latency",
|
||||
"Affected Monitors (%count)": "Затронуті монітори (%count)",
|
||||
"All Systems Operational": "Усі системи працюють",
|
||||
"Average Latency": "Середня затримка",
|
||||
"Avg Latency": "Сер. затримка",
|
||||
"Back": "Назад",
|
||||
"Badges": "Бейджі",
|
||||
"CANCELLED": "СКАСОВАНО",
|
||||
"COMPLETED": "ЗАВЕРШЕНО",
|
||||
"Continue": "Продовжити",
|
||||
"Copied": "Скопійовано",
|
||||
"Current": "Поточні",
|
||||
"Dark": "Темна",
|
||||
"Day": "День",
|
||||
"Day Uptime": "Час роботи за день",
|
||||
"Days": "Дні",
|
||||
"Degraded": "Погіршення",
|
||||
"DEGRADED": "ПОГІРШЕННЯ",
|
||||
"Degraded Performance": "Зниження продуктивності",
|
||||
"Didn't receive the code? Resend": "Не отримали код? Надіслати повторно",
|
||||
"Down": "Недоступний",
|
||||
"DOWN": "НЕ ПРАЦЮЄ",
|
||||
"Duration": "Тривалість",
|
||||
"Email address": "Адреса електронної пошти",
|
||||
"Embed Monitor": "Вбудувати монітор",
|
||||
"Embed this monitor in your website or app": "Вбудуйте цей монітор у свій сайт або застосунок",
|
||||
"End Time": "Час завершення",
|
||||
"Enter the verification code sent to your email.": "Введіть код підтвердження, надісланий на вашу пошту.",
|
||||
"Events": "Події",
|
||||
"Failed to load data": "Не вдалося завантажити дані",
|
||||
"Failed to load latency data": "Не вдалося завантажити дані затримки",
|
||||
"Failed to load status data for this day": "Не вдалося завантажити дані статусу за цей день",
|
||||
"Failed to send verification code": "Не вдалося надіслати код підтвердження",
|
||||
"Failed to update preference": "Не вдалося оновити налаштування",
|
||||
"Get badges for this monitor": "Отримати бейджі для цього монітора",
|
||||
"Get notified about incidents and scheduled maintenance.": "Отримуйте сповіщення про інциденти та планове обслуговування",
|
||||
"Get notified about incidents updates": "Отримуйте сповіщення про оновлення інцидентів",
|
||||
"Get notified about scheduled maintenance": "Отримуйте сповіщення про планове обслуговування",
|
||||
"IDENTIFIED": "ВИЗНАЧЕНО",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Вплив",
|
||||
"incident": "інцидент",
|
||||
"Incident Updates": "Оновлення інцидентів",
|
||||
"Incidents": "Інциденти",
|
||||
"Included Monitors (%count)": "Включені монітори (%count)",
|
||||
"INVESTIGATING": "ДОСЛІДЖЕННЯ",
|
||||
"Last Updated": "Останнє оновлення",
|
||||
"Latency": "Затримка",
|
||||
"Latency Embed": "Вбудована затримка",
|
||||
"Latency Over Time": "Затримка з часом",
|
||||
"Latency Trend": "Тренд затримки",
|
||||
"Latest Latency": "Остання затримка",
|
||||
"Latest Status": "Останній статус",
|
||||
"Light": "Світла",
|
||||
"Live Status": "Статус у реальному часі",
|
||||
"Loading your preferences...": "Завантаження налаштувань...",
|
||||
"maintenance": "обслуговування",
|
||||
"MAINTENANCE": "ОБСЛУГОВУВАННЯ",
|
||||
"Maintenance Updates": "Оновлення обслуговування",
|
||||
"Maintenances": "Обслуговування",
|
||||
"Major System Outage": "Критичний збій системи",
|
||||
"Manage Site": "Керування сайтом",
|
||||
"Manage your notification preferences.": "Керуйте налаштуваннями сповіщень",
|
||||
"Max Latency": "Макс. затримка",
|
||||
"Maximum Latency": "Максимальна затримка",
|
||||
"Min Latency": "Мін. затримка",
|
||||
"Minimum Latency": "Мінімальна затримка",
|
||||
"Minute-by-minute status data for this day": "Похвилинні дані статусу за цей день",
|
||||
"MONITORING": "МОНІТОРИНГ",
|
||||
"Network error. Please try again.": "Помилка мережі. Спробуйте ще раз",
|
||||
"No Events in %currentMonth": "Немає подій у %currentMonth",
|
||||
"No events to show": "Немає подій для відображення",
|
||||
"No incidents for this day": "Немає інцидентів за цей день",
|
||||
"No latency data available for this day": "Немає даних про затримку за цей день",
|
||||
"No maintenances for this day": "Немає обслуговування за цей день",
|
||||
"No monitors affected": "Жоден монітор не зачеплений",
|
||||
"No monitors available.": "Немає доступних моніторів",
|
||||
"No ongoing maintenances": "Немає поточного обслуговування",
|
||||
"No past maintenances": "Немає минулого обслуговування",
|
||||
"No Status Available": "Статус недоступний",
|
||||
"No upcoming maintenances": "Немає запланованого обслуговування",
|
||||
"No Updates": "Немає оновлень",
|
||||
"No updates yet": "Оновлень поки немає",
|
||||
"Notifications": "Сповіщення",
|
||||
"One-time": "Одноразове",
|
||||
"Ongoing": "Поточні",
|
||||
"Operational": "Працює",
|
||||
"Partial Degraded Performance": "Часткове зниження продуктивності",
|
||||
"Partial System Outage": "Частковий збій системи",
|
||||
"Past": "Минулі",
|
||||
"Per-Minute Status": "Похвилинний статус",
|
||||
"Pinging": "Перевірка доступності",
|
||||
"Please enter a valid email address": "Будь ласка, введіть дійсну електронну адресу",
|
||||
"Please enter the 6-digit verification code": "Будь ласка, введіть 6-значний код підтвердження",
|
||||
"Read less": "Згорнути",
|
||||
"Read more": "Читати більше",
|
||||
"READY": "ГОТОВО",
|
||||
"Recurring": "Повторюване",
|
||||
"RESOLVED": "ВИРІШЕНО",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "ЗАПЛАНОВАНО",
|
||||
"Scheduled Events (%count)": "Заплановані події (%count)",
|
||||
"Script": "Скрипт",
|
||||
"Select Language": "Оберіть мову",
|
||||
"Select latency metric to display": "Оберіть метрику затримки для відображення",
|
||||
"Select Range": "Оберіть діапазон",
|
||||
"Sending...": "Надсилання...",
|
||||
"Standard": "Стандартний",
|
||||
"Start Time": "Час початку",
|
||||
"Status": "Статус",
|
||||
"Status Badge": "Бейдж статусу",
|
||||
"Status Embed": "Вбудований статус",
|
||||
"Status history and latency trend": "Історія статусів та тренд затримки",
|
||||
"Subscribe": "Підписатися",
|
||||
"Subscribe to Updates": "Підписатися на оновлення",
|
||||
"There are no incidents or maintenances scheduled for this month.": "На цей місяць не заплановано інцидентів або обслуговування",
|
||||
"There are no ongoing incidents or maintenance events.": "Наразі немає активних інцидентів або обслуговування",
|
||||
"Total Incidents": "Загальна кількість інцидентів",
|
||||
"Total Maintenances": "Загальна кількість обслуговувань",
|
||||
"Under Maintenance": "На обслуговуванні",
|
||||
"Unknown impact": "Невідомий вплив",
|
||||
"UP": "ПРАЦЮЄ",
|
||||
"Upcoming": "Майбутні",
|
||||
"Update Incident": "Оновити інцидент",
|
||||
"Update Maintenance": "Оновити обслуговування",
|
||||
"Updates": "Оновлення",
|
||||
"Updates (%count)": "Оновлення (%count)",
|
||||
"Uptime": "Час роботи",
|
||||
"Uptime Badge": "Бейдж часу роботи",
|
||||
"Verification failed": "Перевірка не вдалася",
|
||||
"Verify": "Підтвердити",
|
||||
"Verifying": "Перевірка",
|
||||
"We sent a 6-digit code to": "Ми надіслали 6-значний код на"
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,11 @@
|
||||
"Day Uptime": "Thời gian hoạt động trong ngày",
|
||||
"Days": "Ngày",
|
||||
"Degraded": "Suy giảm",
|
||||
"DEGRADED": "SUY GIẢM",
|
||||
"Degraded Performance": "Hiệu suất suy giảm",
|
||||
"Didn't receive the code? Resend": "Không nhận được mã? ",
|
||||
"Down": "Ngừng hoạt động",
|
||||
"DOWN": "NGỪNG HOẠT ĐỘNG",
|
||||
"Duration": "Khoảng thời gian",
|
||||
"Email address": "Địa chỉ email",
|
||||
"Embed Monitor": "Màn hình nhúng",
|
||||
@@ -40,10 +42,10 @@
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Sự va chạm",
|
||||
"incident": "Sự cố",
|
||||
"Incident Updates": "Cập nhật sự cố",
|
||||
"Incidents": "Sự cố",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"incident": "Sự cố",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Cập nhật lần cuối",
|
||||
"Latency": "Độ trễ",
|
||||
@@ -55,9 +57,10 @@
|
||||
"Light": "Ánh sáng",
|
||||
"Live Status": "Trạng thái trực tiếp",
|
||||
"Loading your preferences...": "Đang tải tùy chọn của bạn...",
|
||||
"maintenance": "Bảo trì",
|
||||
"MAINTENANCE": "BẢO TRÌ",
|
||||
"Maintenance Updates": "Cập nhật bảo trì",
|
||||
"Maintenances": "Bảo trì",
|
||||
"maintenance": "Bảo trì",
|
||||
"Major System Outage": "Sự cố hệ thống nghiêm trọng",
|
||||
"Manage Site": "Quản lý trang",
|
||||
"Manage your notification preferences.": "Quản lý tùy chọn thông báo của bạn.",
|
||||
@@ -97,6 +100,7 @@
|
||||
"READY": "SẴN SÀNG",
|
||||
"Recurring": "Định kỳ",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "ĐÃ LÊN LỊCH",
|
||||
"Scheduled Events (%count)": "Sự kiện đã lên lịch (%count)",
|
||||
"Script": "Kịch bản",
|
||||
@@ -118,7 +122,9 @@
|
||||
"Total Maintenances": "Tổng số lần bảo trì",
|
||||
"Under Maintenance": "Đang bảo trì",
|
||||
"Unknown impact": "Tác động không xác định",
|
||||
"UP": "HOẠT ĐỘNG",
|
||||
"Upcoming": "Sắp tới",
|
||||
"Update Incident": "Cập nhật sự cố",
|
||||
"Update Maintenance": "Cập nhật bảo trì",
|
||||
"Updates": "Cập nhật",
|
||||
"Updates (%count)": "Cập nhật (%count)",
|
||||
|
||||
+46
-40
@@ -2,62 +2,65 @@
|
||||
"name": "简体中文",
|
||||
"mappings": {
|
||||
"%latency %metric latency": "%latency %metric 延迟",
|
||||
"Affected Monitors (%count)": "受影响的监视器 (%count)",
|
||||
"Affected Monitors (%count)": "受影响的监控项 (%count)",
|
||||
"All Systems Operational": "所有系统运行正常",
|
||||
"Average Latency": "平均延迟",
|
||||
"Avg Latency": "平均延迟",
|
||||
"Back": "后退",
|
||||
"Back": "返回",
|
||||
"Badges": "徽章",
|
||||
"CANCELLED": "已取消",
|
||||
"COMPLETED": "已完成",
|
||||
"Continue": "继续",
|
||||
"Copied": "已复制",
|
||||
"Current": "当前",
|
||||
"Dark": "黑暗的",
|
||||
"Dark": "深色模式",
|
||||
"Day": "天",
|
||||
"Day Uptime": "日间正常运行时间",
|
||||
"Day Uptime": "今日正常运行时长",
|
||||
"Days": "天",
|
||||
"Degraded": "降级",
|
||||
"Degraded": "系统降级",
|
||||
"DEGRADED": "系统降级",
|
||||
"Degraded Performance": "性能下降",
|
||||
"Didn't receive the code? Resend": "没有收到代码?",
|
||||
"Didn't receive the code? Resend": "没有收到代码?重新发送",
|
||||
"Down": "宕机",
|
||||
"Duration": "期间",
|
||||
"DOWN": "故障",
|
||||
"Duration": "持续时间",
|
||||
"Email address": "电子邮件",
|
||||
"Embed Monitor": "嵌入监视器",
|
||||
"Embed this monitor in your website or app": "将此监视器嵌入您的网站或应用程序中",
|
||||
"Embed Monitor": "嵌入监控项",
|
||||
"Embed this monitor in your website or app": "将此监控项嵌入您的网站或应用程序中",
|
||||
"End Time": "结束时间",
|
||||
"Enter the verification code sent to your email.": "输入发送到您的电子邮件的验证码。",
|
||||
"Events": "活动",
|
||||
"Events": "动态",
|
||||
"Failed to load data": "加载数据失败",
|
||||
"Failed to load latency data": "加载延迟数据失败",
|
||||
"Failed to load status data for this day": "无法加载当天的状态数据",
|
||||
"Failed to send verification code": "发送验证码失败",
|
||||
"Failed to update preference": "无法更新偏好设置",
|
||||
"Get badges for this monitor": "获取此显示器的徽章",
|
||||
"Get badges for this monitor": "获取此监控项的徽章",
|
||||
"Get notified about incidents and scheduled maintenance.": "获取有关事件和定期维护的通知。",
|
||||
"Get notified about incidents updates": "获取有关事件更新的通知",
|
||||
"Get notified about scheduled maintenance": "获取有关定期维护的通知",
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"IDENTIFIED": "已确认",
|
||||
"iFrame": "框架",
|
||||
"Impact": "影响",
|
||||
"incident": "事件",
|
||||
"Incident Updates": "事件更新",
|
||||
"Incidents": "事件",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"incident": "事件",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Included Monitors (%count)": "包括的监控项 (%count)",
|
||||
"INVESTIGATING": "调查中",
|
||||
"Last Updated": "最后更新",
|
||||
"Latency": "延迟",
|
||||
"Latency Embed": "延迟嵌入",
|
||||
"Latency Over Time": "随着时间的推移延迟",
|
||||
"Latency Embed": "嵌入延迟",
|
||||
"Latency Over Time": "历史延迟",
|
||||
"Latency Trend": "延迟趋势",
|
||||
"Latest Latency": "最新延迟时间",
|
||||
"Latest Status": "最新状态",
|
||||
"Light": "光",
|
||||
"Light": "浅色模式",
|
||||
"Live Status": "实时状态",
|
||||
"Loading your preferences...": "正在加载您的偏好设置...",
|
||||
"Maintenance Updates": "维护更新",
|
||||
"Maintenances": "维护保养",
|
||||
"maintenance": "维护",
|
||||
"MAINTENANCE": "维护中",
|
||||
"Maintenance Updates": "维护更新",
|
||||
"Maintenances": "例行维护",
|
||||
"Major System Outage": "重大系统故障",
|
||||
"Manage Site": "管理站点",
|
||||
"Manage your notification preferences.": "管理您的通知首选项。",
|
||||
@@ -66,38 +69,39 @@
|
||||
"Min Latency": "最短延迟",
|
||||
"Minimum Latency": "最短延迟",
|
||||
"Minute-by-minute status data for this day": "当日每分钟的状态数据",
|
||||
"MONITORING": "MONITORING",
|
||||
"Network error. Please try again.": "网络错误。",
|
||||
"MONITORING": "监视中",
|
||||
"Network error. Please try again.": "网络错误。请稍后再试。",
|
||||
"No Events in %currentMonth": "%currentMonth 没有活动",
|
||||
"No events to show": "没有可显示的事件",
|
||||
"No incidents for this day": "这一天没有发生任何事件",
|
||||
"No latency data available for this day": "当天没有可用的延迟数据",
|
||||
"No maintenances for this day": "今日无维护",
|
||||
"No monitors affected": "没有显示器受到影响",
|
||||
"No maintenances for this day": "这一天无维护",
|
||||
"No monitors affected": "没有监控项受到影响",
|
||||
"No monitors available.": "没有可用的监控项。",
|
||||
"No ongoing maintenances": "无需持续维护",
|
||||
"No ongoing maintenances": "没有需要持续的维护",
|
||||
"No past maintenances": "过去没有维护过",
|
||||
"No Status Available": "无可用状态",
|
||||
"No upcoming maintenances": "没有即将进行的维护",
|
||||
"No Updates": "没有更新",
|
||||
"No updates yet": "还没有更新",
|
||||
"No updates yet": "暂时没有更新",
|
||||
"Notifications": "通知",
|
||||
"One-time": "一度",
|
||||
"One-time": "单次",
|
||||
"Ongoing": "进行中",
|
||||
"Operational": "正常运行",
|
||||
"Partial Degraded Performance": "部分性能下降",
|
||||
"Partial System Outage": "部分系统故障.",
|
||||
"Partial System Outage": "部分系统故障",
|
||||
"Past": "过去的",
|
||||
"Per-Minute Status": "每分钟状态",
|
||||
"Pinging": "pinging",
|
||||
"Pinging": "检测中",
|
||||
"Please enter a valid email address": "请输入有效的电子邮件地址",
|
||||
"Please enter the 6-digit verification code": "请输入6位验证码",
|
||||
"Read less": "少读书",
|
||||
"Read more": "阅读更多",
|
||||
"Read less": "收起",
|
||||
"Read more": "更多",
|
||||
"READY": "就绪",
|
||||
"Recurring": "周期性",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"SCHEDULED": "已安排",
|
||||
"RESOLVED": "已解决",
|
||||
"RSS feed": "RSS 订阅源",
|
||||
"SCHEDULED": "已计划",
|
||||
"Scheduled Events (%count)": "计划事件 (%count)",
|
||||
"Script": "脚本",
|
||||
"Select Language": "选择语言",
|
||||
@@ -106,26 +110,28 @@
|
||||
"Sending...": "正在发送...",
|
||||
"Standard": "标准",
|
||||
"Start Time": "开始时间",
|
||||
"Status": "地位",
|
||||
"Status": "状态",
|
||||
"Status Badge": "状态徽章",
|
||||
"Status Embed": "状态嵌入",
|
||||
"Status history and latency trend": "状态历史和延迟趋势",
|
||||
"Subscribe": "订阅",
|
||||
"Subscribe to Updates": "订阅更新",
|
||||
"There are no incidents or maintenances scheduled for this month.": "本月没有安排任何事故或维护。",
|
||||
"There are no ongoing incidents or maintenance events.": "当前没有正在进行的事件或维护活动。",
|
||||
"Total Incidents": "事故总数",
|
||||
"Total Maintenances": "全面维护",
|
||||
"There are no incidents or maintenances scheduled for this month.": "本月没有任何事故或安排的维护。",
|
||||
"There are no ongoing incidents or maintenance events.": "当前没有正在进行的事件或维护。",
|
||||
"Total Incidents": "事件总数",
|
||||
"Total Maintenances": "维护总数",
|
||||
"Under Maintenance": "维护中",
|
||||
"Unknown impact": "未知影响",
|
||||
"Upcoming": "即将推出",
|
||||
"UP": "正常",
|
||||
"Upcoming": "即将进行",
|
||||
"Update Incident": "更新事件",
|
||||
"Update Maintenance": "更新维护",
|
||||
"Updates": "更新",
|
||||
"Updates (%count)": "更新 (%count)",
|
||||
"Uptime": "正常运行时间",
|
||||
"Uptime Badge": "正常运行时间徽章",
|
||||
"Verification failed": "验证失败",
|
||||
"Verify": "核实",
|
||||
"Verify": "验证",
|
||||
"Verifying": "正在验证",
|
||||
"We sent a 6-digit code to": "我们发送了一个 6 位代码至"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"name": "繁體中文(香港)",
|
||||
"mappings": {
|
||||
"%latency %metric latency": "%latency %metric 延遲",
|
||||
"Affected Monitors (%count)": "受影響的監控項 (%count)",
|
||||
"All Systems Operational": "所有系統運行正常",
|
||||
"Average Latency": "平均延遲",
|
||||
"Avg Latency": "平均延遲",
|
||||
"Back": "返回",
|
||||
"Badges": "徽章",
|
||||
"CANCELLED": "已取消",
|
||||
"COMPLETED": "已完成",
|
||||
"Continue": "繼續",
|
||||
"Copied": "已複製",
|
||||
"Current": "當前",
|
||||
"Dark": "深色模式",
|
||||
"Day": "天",
|
||||
"Day Uptime": "今日正常運行時長",
|
||||
"Days": "天",
|
||||
"Degraded": "系統降級",
|
||||
"DEGRADED": "系統降級",
|
||||
"Degraded Performance": "效能下降",
|
||||
"Didn't receive the code? Resend": "沒有收到驗證碼?重新發送",
|
||||
"Down": "當機",
|
||||
"DOWN": "故障",
|
||||
"Duration": "持續時間",
|
||||
"Email address": "電郵地址",
|
||||
"Embed Monitor": "嵌入監控項",
|
||||
"Embed this monitor in your website or app": "將此監控項嵌入您的網站或應用程式中",
|
||||
"End Time": "結束時間",
|
||||
"Enter the verification code sent to your email.": "輸入發送到您電郵的驗證碼。",
|
||||
"Events": "動態",
|
||||
"Failed to load data": "載入數據失敗",
|
||||
"Failed to load latency data": "載入延遲數據失敗",
|
||||
"Failed to load status data for this day": "無法載入當天的狀態數據",
|
||||
"Failed to send verification code": "發送驗證碼失敗",
|
||||
"Failed to update preference": "無法更新偏好設定",
|
||||
"Get badges for this monitor": "獲取此監控項的徽章",
|
||||
"Get notified about incidents and scheduled maintenance.": "獲取有關事件和定期維護的通知。",
|
||||
"Get notified about incidents updates": "獲取有關事件更新的通知",
|
||||
"Get notified about scheduled maintenance": "獲取有關定期維護的通知",
|
||||
"IDENTIFIED": "已確認",
|
||||
"iFrame": "框架",
|
||||
"Impact": "影響",
|
||||
"incident": "事件",
|
||||
"Incident Updates": "事件更新",
|
||||
"Incidents": "事件",
|
||||
"Included Monitors (%count)": "包括的監控項 (%count)",
|
||||
"INVESTIGATING": "調查中",
|
||||
"Last Updated": "最後更新",
|
||||
"Latency": "延遲",
|
||||
"Latency Embed": "嵌入延遲",
|
||||
"Latency Over Time": "歷史延遲",
|
||||
"Latency Trend": "延遲趨勢",
|
||||
"Latest Latency": "最新延遲時間",
|
||||
"Latest Status": "最新狀態",
|
||||
"Light": "淺色模式",
|
||||
"Live Status": "即時狀態",
|
||||
"Loading your preferences...": "正在載入您的偏好設定...",
|
||||
"maintenance": "維護",
|
||||
"MAINTENANCE": "維護中",
|
||||
"Maintenance Updates": "維護更新",
|
||||
"Maintenances": "例行維護",
|
||||
"Major System Outage": "重大系統故障",
|
||||
"Manage Site": "管理站點",
|
||||
"Manage your notification preferences.": "管理您的通知偏好設定。",
|
||||
"Max Latency": "最大延遲",
|
||||
"Maximum Latency": "最大延遲",
|
||||
"Min Latency": "最短延遲",
|
||||
"Minimum Latency": "最短延遲",
|
||||
"Minute-by-minute status data for this day": "當日每分鐘的狀態數據",
|
||||
"MONITORING": "監察中",
|
||||
"Network error. Please try again.": "網絡錯誤。請稍後再試。",
|
||||
"No Events in %currentMonth": "%currentMonth 沒有活動",
|
||||
"No events to show": "沒有可顯示的事件",
|
||||
"No incidents for this day": "這一天沒有發生任何事件",
|
||||
"No latency data available for this day": "當天沒有可用的延遲數據",
|
||||
"No maintenances for this day": "這一天無維護",
|
||||
"No monitors affected": "沒有監控項受到影響",
|
||||
"No monitors available.": "沒有可用的監控項。",
|
||||
"No ongoing maintenances": "沒有需要持續的維護",
|
||||
"No past maintenances": "過去沒有維護過",
|
||||
"No Status Available": "無可用狀態",
|
||||
"No upcoming maintenances": "沒有即將進行的維護",
|
||||
"No Updates": "沒有更新",
|
||||
"No updates yet": "暫時沒有更新",
|
||||
"Notifications": "通知",
|
||||
"One-time": "單次",
|
||||
"Ongoing": "進行中",
|
||||
"Operational": "正常運行",
|
||||
"Partial Degraded Performance": "部分效能下降",
|
||||
"Partial System Outage": "部分系統故障",
|
||||
"Past": "過去的",
|
||||
"Per-Minute Status": "每分鐘狀態",
|
||||
"Pinging": "檢測中",
|
||||
"Please enter a valid email address": "請輸入有效的電郵地址",
|
||||
"Please enter the 6-digit verification code": "請輸入6位驗證碼",
|
||||
"Read less": "收起",
|
||||
"Read more": "更多",
|
||||
"READY": "就緒",
|
||||
"Recurring": "週期性",
|
||||
"RESOLVED": "已解決",
|
||||
"RSS feed": "RSS 訂閱源",
|
||||
"SCHEDULED": "已計劃",
|
||||
"Scheduled Events (%count)": "計劃事件 (%count)",
|
||||
"Script": "腳本",
|
||||
"Select Language": "選擇語言",
|
||||
"Select latency metric to display": "選擇要顯示的延遲指標",
|
||||
"Select Range": "選擇範圍",
|
||||
"Sending...": "正在發送...",
|
||||
"Standard": "標準",
|
||||
"Start Time": "開始時間",
|
||||
"Status": "狀態",
|
||||
"Status Badge": "狀態徽章",
|
||||
"Status Embed": "狀態嵌入",
|
||||
"Status history and latency trend": "狀態歷史和延遲趨勢",
|
||||
"Subscribe": "訂閱",
|
||||
"Subscribe to Updates": "訂閱更新",
|
||||
"There are no incidents or maintenances scheduled for this month.": "本月沒有任何事故或安排的維護。",
|
||||
"There are no ongoing incidents or maintenance events.": "當前沒有正在進行的事件或維護。",
|
||||
"Total Incidents": "事件總數",
|
||||
"Total Maintenances": "維護總數",
|
||||
"Under Maintenance": "維護中",
|
||||
"Unknown impact": "未知影響",
|
||||
"UP": "正常",
|
||||
"Upcoming": "即將進行",
|
||||
"Update Incident": "更新事件",
|
||||
"Update Maintenance": "更新維護",
|
||||
"Updates": "更新",
|
||||
"Updates (%count)": "更新 (%count)",
|
||||
"Uptime": "正常運行時間",
|
||||
"Uptime Badge": "正常運行時間徽章",
|
||||
"Verification failed": "驗證失敗",
|
||||
"Verify": "驗證",
|
||||
"Verifying": "正在驗證",
|
||||
"We sent a 6-digit code to": "我們已發送一個6位驗證碼至"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"name": "繁體中文(澳門)",
|
||||
"mappings": {
|
||||
"%latency %metric latency": "%latency %metric 延遲",
|
||||
"Affected Monitors (%count)": "受影響的監控項 (%count)",
|
||||
"All Systems Operational": "所有系統運行正常",
|
||||
"Average Latency": "平均延遲",
|
||||
"Avg Latency": "平均延遲",
|
||||
"Back": "返回",
|
||||
"Badges": "徽章",
|
||||
"CANCELLED": "已取消",
|
||||
"COMPLETED": "已完成",
|
||||
"Continue": "繼續",
|
||||
"Copied": "已複製",
|
||||
"Current": "當前",
|
||||
"Dark": "深色模式",
|
||||
"Day": "天",
|
||||
"Day Uptime": "今日正常運行時長",
|
||||
"Days": "天",
|
||||
"Degraded": "系統降級",
|
||||
"DEGRADED": "系統降級",
|
||||
"Degraded Performance": "效能下降",
|
||||
"Didn't receive the code? Resend": "沒有收到驗證碼?重新發送",
|
||||
"Down": "當機",
|
||||
"DOWN": "故障",
|
||||
"Duration": "持續時間",
|
||||
"Email address": "電郵地址",
|
||||
"Embed Monitor": "嵌入監控項",
|
||||
"Embed this monitor in your website or app": "將此監控項嵌入您的網站或應用程式中",
|
||||
"End Time": "結束時間",
|
||||
"Enter the verification code sent to your email.": "輸入發送到您電郵的驗證碼。",
|
||||
"Events": "動態",
|
||||
"Failed to load data": "載入數據失敗",
|
||||
"Failed to load latency data": "載入延遲數據失敗",
|
||||
"Failed to load status data for this day": "無法載入當天的狀態數據",
|
||||
"Failed to send verification code": "發送驗證碼失敗",
|
||||
"Failed to update preference": "無法更新偏好設定",
|
||||
"Get badges for this monitor": "獲取此監控項的徽章",
|
||||
"Get notified about incidents and scheduled maintenance.": "獲取有關事件和定期維護的通知。",
|
||||
"Get notified about incidents updates": "獲取有關事件更新的通知",
|
||||
"Get notified about scheduled maintenance": "獲取有關定期維護的通知",
|
||||
"IDENTIFIED": "已確認",
|
||||
"iFrame": "框架",
|
||||
"Impact": "影響",
|
||||
"incident": "事件",
|
||||
"Incident Updates": "事件更新",
|
||||
"Incidents": "事件",
|
||||
"Included Monitors (%count)": "包括的監控項 (%count)",
|
||||
"INVESTIGATING": "調查中",
|
||||
"Last Updated": "最後更新",
|
||||
"Latency": "延遲",
|
||||
"Latency Embed": "嵌入延遲",
|
||||
"Latency Over Time": "歷史延遲",
|
||||
"Latency Trend": "延遲趨勢",
|
||||
"Latest Latency": "最新延遲時間",
|
||||
"Latest Status": "最新狀態",
|
||||
"Light": "淺色模式",
|
||||
"Live Status": "即時狀態",
|
||||
"Loading your preferences...": "正在載入您的偏好設定...",
|
||||
"maintenance": "維護",
|
||||
"MAINTENANCE": "維護中",
|
||||
"Maintenance Updates": "維護更新",
|
||||
"Maintenances": "例行維護",
|
||||
"Major System Outage": "重大系統故障",
|
||||
"Manage Site": "管理站點",
|
||||
"Manage your notification preferences.": "管理您的通知偏好設定。",
|
||||
"Max Latency": "最大延遲",
|
||||
"Maximum Latency": "最大延遲",
|
||||
"Min Latency": "最短延遲",
|
||||
"Minimum Latency": "最短延遲",
|
||||
"Minute-by-minute status data for this day": "當日每分鐘的狀態數據",
|
||||
"MONITORING": "監察中",
|
||||
"Network error. Please try again.": "網絡錯誤。請稍後再試。",
|
||||
"No Events in %currentMonth": "%currentMonth 沒有活動",
|
||||
"No events to show": "沒有可顯示的事件",
|
||||
"No incidents for this day": "這一天沒有發生任何事件",
|
||||
"No latency data available for this day": "當天沒有可用的延遲數據",
|
||||
"No maintenances for this day": "這一天無維護",
|
||||
"No monitors affected": "沒有監控項受到影響",
|
||||
"No monitors available.": "沒有可用的監控項。",
|
||||
"No ongoing maintenances": "沒有需要持續的維護",
|
||||
"No past maintenances": "過去沒有維護過",
|
||||
"No Status Available": "無可用狀態",
|
||||
"No upcoming maintenances": "沒有即將進行的維護",
|
||||
"No Updates": "沒有更新",
|
||||
"No updates yet": "暫時沒有更新",
|
||||
"Notifications": "通知",
|
||||
"One-time": "單次",
|
||||
"Ongoing": "進行中",
|
||||
"Operational": "正常運行",
|
||||
"Partial Degraded Performance": "部分效能下降",
|
||||
"Partial System Outage": "部分系統故障",
|
||||
"Past": "過去的",
|
||||
"Per-Minute Status": "每分鐘狀態",
|
||||
"Pinging": "檢測中",
|
||||
"Please enter a valid email address": "請輸入有效的電郵地址",
|
||||
"Please enter the 6-digit verification code": "請輸入6位驗證碼",
|
||||
"Read less": "收起",
|
||||
"Read more": "更多",
|
||||
"READY": "就緒",
|
||||
"Recurring": "週期性",
|
||||
"RESOLVED": "已解決",
|
||||
"RSS feed": "RSS 訂閱源",
|
||||
"SCHEDULED": "已計劃",
|
||||
"Scheduled Events (%count)": "計劃事件 (%count)",
|
||||
"Script": "腳本",
|
||||
"Select Language": "選擇語言",
|
||||
"Select latency metric to display": "選擇要顯示的延遲指標",
|
||||
"Select Range": "選擇範圍",
|
||||
"Sending...": "正在發送...",
|
||||
"Standard": "標準",
|
||||
"Start Time": "開始時間",
|
||||
"Status": "狀態",
|
||||
"Status Badge": "狀態徽章",
|
||||
"Status Embed": "狀態嵌入",
|
||||
"Status history and latency trend": "狀態歷史和延遲趨勢",
|
||||
"Subscribe": "訂閱",
|
||||
"Subscribe to Updates": "訂閱更新",
|
||||
"There are no incidents or maintenances scheduled for this month.": "本月沒有任何事故或安排的維護。",
|
||||
"There are no ongoing incidents or maintenance events.": "當前沒有正在進行的事件或維護。",
|
||||
"Total Incidents": "事件總數",
|
||||
"Total Maintenances": "維護總數",
|
||||
"Under Maintenance": "維護中",
|
||||
"Unknown impact": "未知影響",
|
||||
"UP": "正常",
|
||||
"Upcoming": "即將進行",
|
||||
"Update Incident": "更新事件",
|
||||
"Update Maintenance": "更新維護",
|
||||
"Updates": "更新",
|
||||
"Updates (%count)": "更新 (%count)",
|
||||
"Uptime": "正常運行時間",
|
||||
"Uptime Badge": "正常運行時間徽章",
|
||||
"Verification failed": "驗證失敗",
|
||||
"Verify": "驗證",
|
||||
"Verifying": "正在驗證",
|
||||
"We sent a 6-digit code to": "我們已發送一個6位驗證碼至"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"name": "繁體中文(台灣)",
|
||||
"mappings": {
|
||||
"%latency %metric latency": "%latency %metric 延遲",
|
||||
"Affected Monitors (%count)": "受影響的監控項 (%count)",
|
||||
"All Systems Operational": "所有系統運行正常",
|
||||
"Average Latency": "平均延遲",
|
||||
"Avg Latency": "平均延遲",
|
||||
"Back": "返回",
|
||||
"Badges": "徽章",
|
||||
"CANCELLED": "已取消",
|
||||
"COMPLETED": "已完成",
|
||||
"Continue": "繼續",
|
||||
"Copied": "已複製",
|
||||
"Current": "當前",
|
||||
"Dark": "深色模式",
|
||||
"Day": "天",
|
||||
"Day Uptime": "今日正常運行時長",
|
||||
"Days": "天",
|
||||
"Degraded": "系統降級",
|
||||
"DEGRADED": "系統降級",
|
||||
"Degraded Performance": "效能下降",
|
||||
"Didn't receive the code? Resend": "沒有收到代碼?重新發送",
|
||||
"Down": "當機",
|
||||
"DOWN": "故障",
|
||||
"Duration": "持續時間",
|
||||
"Email address": "電子郵件",
|
||||
"Embed Monitor": "嵌入監控項",
|
||||
"Embed this monitor in your website or app": "將此監控項嵌入您的網站或應用程式中",
|
||||
"End Time": "結束時間",
|
||||
"Enter the verification code sent to your email.": "輸入發送到您的電子郵件的驗證碼。",
|
||||
"Events": "動態",
|
||||
"Failed to load data": "載入數據失敗",
|
||||
"Failed to load latency data": "載入延遲數據失敗",
|
||||
"Failed to load status data for this day": "無法載入當天的狀態數據",
|
||||
"Failed to send verification code": "發送驗證碼失敗",
|
||||
"Failed to update preference": "無法更新偏好設定",
|
||||
"Get badges for this monitor": "獲取此監控項的徽章",
|
||||
"Get notified about incidents and scheduled maintenance.": "獲取有關事件和定期維護的通知。",
|
||||
"Get notified about incidents updates": "獲取有關事件更新的通知",
|
||||
"Get notified about scheduled maintenance": "獲取有關定期維護的通知",
|
||||
"IDENTIFIED": "已確認",
|
||||
"iFrame": "框架",
|
||||
"Impact": "影響",
|
||||
"incident": "事件",
|
||||
"Incident Updates": "事件更新",
|
||||
"Incidents": "事件",
|
||||
"Included Monitors (%count)": "包括的監控項 (%count)",
|
||||
"INVESTIGATING": "調查中",
|
||||
"Last Updated": "最後更新",
|
||||
"Latency": "延遲",
|
||||
"Latency Embed": "嵌入延遲",
|
||||
"Latency Over Time": "歷史延遲",
|
||||
"Latency Trend": "延遲趨勢",
|
||||
"Latest Latency": "最新延遲時間",
|
||||
"Latest Status": "最新狀態",
|
||||
"Light": "淺色模式",
|
||||
"Live Status": "即時狀態",
|
||||
"Loading your preferences...": "正在載入您的偏好設定...",
|
||||
"maintenance": "維護",
|
||||
"MAINTENANCE": "維護中",
|
||||
"Maintenance Updates": "維護更新",
|
||||
"Maintenances": "例行維護",
|
||||
"Major System Outage": "重大系統故障",
|
||||
"Manage Site": "管理站點",
|
||||
"Manage your notification preferences.": "管理您的通知首選項。",
|
||||
"Max Latency": "最大延遲",
|
||||
"Maximum Latency": "最大延遲",
|
||||
"Min Latency": "最短延遲",
|
||||
"Minimum Latency": "最短延遲",
|
||||
"Minute-by-minute status data for this day": "當日每分鐘的狀態數據",
|
||||
"MONITORING": "監視中",
|
||||
"Network error. Please try again.": "網路錯誤。請稍後再試。",
|
||||
"No Events in %currentMonth": "%currentMonth 沒有活動",
|
||||
"No events to show": "沒有可顯示的事件",
|
||||
"No incidents for this day": "這一天沒有發生任何事件",
|
||||
"No latency data available for this day": "當天沒有可用的延遲數據",
|
||||
"No maintenances for this day": "這一天無維護",
|
||||
"No monitors affected": "沒有監控項受到影響",
|
||||
"No monitors available.": "沒有可用的監控項。",
|
||||
"No ongoing maintenances": "沒有需要持續的維護",
|
||||
"No past maintenances": "過去沒有維護過",
|
||||
"No Status Available": "無可用狀態",
|
||||
"No upcoming maintenances": "沒有即將進行的維護",
|
||||
"No Updates": "沒有更新",
|
||||
"No updates yet": "暫時沒有更新",
|
||||
"Notifications": "通知",
|
||||
"One-time": "單次",
|
||||
"Ongoing": "進行中",
|
||||
"Operational": "正常運行",
|
||||
"Partial Degraded Performance": "部分效能下降",
|
||||
"Partial System Outage": "部分系統故障",
|
||||
"Past": "過去的",
|
||||
"Per-Minute Status": "每分鐘狀態",
|
||||
"Pinging": "檢測中",
|
||||
"Please enter a valid email address": "請輸入有效的電子郵件地址",
|
||||
"Please enter the 6-digit verification code": "請輸入6位驗證碼",
|
||||
"Read less": "收起",
|
||||
"Read more": "更多",
|
||||
"READY": "就緒",
|
||||
"Recurring": "週期性",
|
||||
"RESOLVED": "已解決",
|
||||
"RSS feed": "RSS 訂閱源",
|
||||
"SCHEDULED": "已計劃",
|
||||
"Scheduled Events (%count)": "計劃事件 (%count)",
|
||||
"Script": "腳本",
|
||||
"Select Language": "選擇語言",
|
||||
"Select latency metric to display": "選擇要顯示的延遲指標",
|
||||
"Select Range": "選擇範圍",
|
||||
"Sending...": "正在發送...",
|
||||
"Standard": "標準",
|
||||
"Start Time": "開始時間",
|
||||
"Status": "狀態",
|
||||
"Status Badge": "狀態徽章",
|
||||
"Status Embed": "狀態嵌入",
|
||||
"Status history and latency trend": "狀態歷史和延遲趨勢",
|
||||
"Subscribe": "訂閱",
|
||||
"Subscribe to Updates": "訂閱更新",
|
||||
"There are no incidents or maintenances scheduled for this month.": "本月沒有任何事故或安排的維護。",
|
||||
"There are no ongoing incidents or maintenance events.": "當前沒有正在進行的事件或維護。",
|
||||
"Total Incidents": "事件總數",
|
||||
"Total Maintenances": "維護總數",
|
||||
"Under Maintenance": "維護中",
|
||||
"Unknown impact": "未知影響",
|
||||
"UP": "正常",
|
||||
"Upcoming": "即將進行",
|
||||
"Update Incident": "更新事件",
|
||||
"Update Maintenance": "更新維護",
|
||||
"Updates": "更新",
|
||||
"Updates (%count)": "更新 (%count)",
|
||||
"Uptime": "正常運行時間",
|
||||
"Uptime Badge": "正常運行時間徽章",
|
||||
"Verification failed": "驗證失敗",
|
||||
"Verify": "驗證",
|
||||
"Verifying": "正在驗證",
|
||||
"We sent a 6-digit code to": "我們發送了一個 6 位代碼至"
|
||||
}
|
||||
}
|
||||
@@ -17,26 +17,19 @@ import type {
|
||||
import type { GroupMonitorTypeData } from "../types/monitor.js";
|
||||
import GC from "../../global-constants.js";
|
||||
import type { LayoutServerData } from "./layoutController.js";
|
||||
import type { NotificationEvent } from "../../types/notifications.js";
|
||||
|
||||
export type { NotificationEvent };
|
||||
|
||||
// Default page settings
|
||||
const defaultPageSettings: PageSettingsType = {
|
||||
monitor_status_history_days: {
|
||||
desktop: 90,
|
||||
mobile: 30,
|
||||
desktop: GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP,
|
||||
mobile: GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE,
|
||||
},
|
||||
monitor_layout_style: "default-list",
|
||||
monitor_layout_style: GC.DEFAULT_MONITOR_LAYOUT_STYLE,
|
||||
};
|
||||
|
||||
export interface NotificationEvent {
|
||||
eventURL: string;
|
||||
eventTitle: string;
|
||||
eventDate: string;
|
||||
eventType: string;
|
||||
eventStartDateTime: number;
|
||||
eventEndDateTime: number | null;
|
||||
eventStatus: string;
|
||||
}
|
||||
|
||||
export interface NotificationPayload {
|
||||
notifications: NotificationEvent[];
|
||||
}
|
||||
@@ -159,9 +152,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,27 +339,55 @@ 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;
|
||||
const showInlineEvents = eventSettings.showInlineEvents === true;
|
||||
// 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
|
||||
showInlineEvents && eventSettings.incidents.enabled && eventSettings.incidents.ongoing.show
|
||||
? GetOngoingIncidentsForMonitorList(monitorTags)
|
||||
: Promise.resolve([] as IncidentForMonitorListWithComments[]),
|
||||
eventSettings.maintenances.enabled && eventSettings.maintenances.ongoing.show
|
||||
showInlineEvents && eventSettings.maintenances.enabled && eventSettings.maintenances.ongoing.show
|
||||
? GetOngoingMaintenances(monitorTags, nowTs)
|
||||
: Promise.resolve([] as MaintenanceEventsMonitorList[]),
|
||||
showInlineEvents && 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 +406,12 @@ export const GetPageDashboardData = async (
|
||||
pageStatus,
|
||||
ongoingIncidents,
|
||||
ongoingMaintenances,
|
||||
upcomingMaintenances,
|
||||
monitorTags,
|
||||
monitorGroupMembersByTag,
|
||||
pageDetails: pageDetailsTyped,
|
||||
socialPagePreviewImage,
|
||||
metaPageTitle,
|
||||
metaPageDescription,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -526,3 +526,30 @@ export const ParseIncidentToAPIResp = async (
|
||||
|
||||
return resp;
|
||||
};
|
||||
|
||||
export const DeleteIncident = async (incident_id: number): Promise<{ success: boolean }> => {
|
||||
const incident = await db.getIncidentById(incident_id);
|
||||
if (!incident) {
|
||||
throw new Error(`Incident with id ${incident_id} does not exist`);
|
||||
}
|
||||
|
||||
// Set incident_id to null in monitor_alerts_v2
|
||||
const alerts = await db.getAlertsByIncidentId(incident_id);
|
||||
for (const alert of alerts) {
|
||||
await db.updateMonitorAlertV2(alert.id, { incident_id: null });
|
||||
}
|
||||
|
||||
// Delete incident monitors
|
||||
const monitors = await db.getIncidentMonitorsByIncidentID(incident_id);
|
||||
for (const monitor of monitors) {
|
||||
await db.removeIncidentMonitor(incident_id, monitor.monitor_tag);
|
||||
}
|
||||
|
||||
// Delete incident comments permanently
|
||||
await db.deleteIncidentCommentsByIncidentID(incident_id);
|
||||
|
||||
// Delete the incident
|
||||
await db.deleteIncident(incident_id);
|
||||
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
GetLoggedInSession,
|
||||
GetLocaleFromCookie,
|
||||
GetUsersCount,
|
||||
HasRequiredEnv,
|
||||
IsEmailSetup,
|
||||
IsSetupComplete,
|
||||
} from "./controller.js";
|
||||
import type { EventDisplaySettings, GlobalPageVisibilitySettings } from "$lib/types/site.js";
|
||||
import type { EventDisplaySettings, GlobalPageVisibilitySettings, SiteDateTimeFormat } from "$lib/types/site.js";
|
||||
|
||||
export interface LayoutServerData {
|
||||
isMobile: boolean;
|
||||
@@ -48,6 +48,7 @@ export interface LayoutServerData {
|
||||
subMenuOptions: {
|
||||
showShareBadgeMonitor: boolean;
|
||||
showShareEmbedMonitor: boolean;
|
||||
showRssFeed: boolean;
|
||||
};
|
||||
isTimezoneEnabled: boolean;
|
||||
isThemeToggleEnabled: boolean;
|
||||
@@ -70,6 +71,46 @@ export interface LayoutServerData {
|
||||
socialPreviewImage?: string;
|
||||
customCSS?: string;
|
||||
globalPageVisibilitySettings: GlobalPageVisibilitySettings;
|
||||
dateAndTimeFormat: SiteDateTimeFormat;
|
||||
metaSiteTitle?: string;
|
||||
metaSiteDescription?: string;
|
||||
}
|
||||
|
||||
function NormalizeEventDisplaySettings(settings?: Partial<EventDisplaySettings>): EventDisplaySettings {
|
||||
const defaults = structuredClone(seedSiteData.eventDisplaySettings);
|
||||
|
||||
return {
|
||||
showInlineEvents:
|
||||
typeof settings?.showInlineEvents === "boolean" ? settings.showInlineEvents : defaults.showInlineEvents,
|
||||
incidents: {
|
||||
...defaults.incidents,
|
||||
...settings?.incidents,
|
||||
ongoing: {
|
||||
...defaults.incidents.ongoing,
|
||||
...settings?.incidents?.ongoing,
|
||||
},
|
||||
resolved: {
|
||||
...defaults.incidents.resolved,
|
||||
...settings?.incidents?.resolved,
|
||||
},
|
||||
},
|
||||
maintenances: {
|
||||
...defaults.maintenances,
|
||||
...settings?.maintenances,
|
||||
ongoing: {
|
||||
...defaults.maintenances.ongoing,
|
||||
...settings?.maintenances?.ongoing,
|
||||
},
|
||||
past: {
|
||||
...defaults.maintenances.past,
|
||||
...settings?.maintenances?.past,
|
||||
},
|
||||
upcoming: {
|
||||
...defaults.maintenances.upcoming,
|
||||
...settings?.maintenances?.upcoming,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function GetLayoutServerData(cookies: Cookies, request: Request): Promise<LayoutServerData> {
|
||||
@@ -83,7 +124,9 @@ export async function GetLayoutServerData(cookies: Cookies, request: Request): P
|
||||
GetUsersCount(),
|
||||
]);
|
||||
|
||||
const isSetupComplete = await IsSetupComplete();
|
||||
// Same check as IsSetupComplete, but reuses the site data fetched above
|
||||
// instead of querying it a second time on every request
|
||||
const isSetupComplete = HasRequiredEnv() && Object.keys(siteData).length > 0;
|
||||
|
||||
const selectedLang = GetLocaleFromCookie(siteData, cookies);
|
||||
const siteStatusColors = siteData.colors;
|
||||
@@ -133,9 +176,12 @@ export async function GetLayoutServerData(cookies: Cookies, request: Request): P
|
||||
font,
|
||||
canSendEmail,
|
||||
announcement: siteData.announcement,
|
||||
eventDisplaySettings: siteData.eventDisplaySettings || seedSiteData.eventDisplaySettings,
|
||||
eventDisplaySettings: NormalizeEventDisplaySettings(siteData.eventDisplaySettings),
|
||||
socialPreviewImage: siteData.socialPreviewImage,
|
||||
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;
|
||||
@@ -418,18 +486,75 @@ export const UpdateMaintenanceEvent = async (
|
||||
return await db.updateMaintenanceEvent(id, data);
|
||||
};
|
||||
|
||||
export const UpdateMaintenanceEventStatus = async (id: number, status: string): Promise<number> => {
|
||||
const validStatuses = ["SCHEDULED", "IN_PROGRESS", "COMPLETED", "CANCELLED"];
|
||||
if (!validStatuses.includes(status)) {
|
||||
throw new Error(`Invalid status: ${status}`);
|
||||
/**
|
||||
* Manually transition a maintenance event to a terminal status.
|
||||
* Allowed transitions: ONGOING → COMPLETED, SCHEDULED/READY/ONGOING → CANCELLED.
|
||||
* An event that already started has its end_date_time moved to the moment it was
|
||||
* ended (the record reflects what actually happened); an event that never started
|
||||
* keeps its planned window. See docs/adr/0006-manual-maintenance-event-transitions.md
|
||||
*/
|
||||
export const UpdateMaintenanceEventStatus = async (id: number, status: string): Promise<MaintenanceEventRecord> => {
|
||||
if (status !== GC.COMPLETED && status !== GC.CANCELLED) {
|
||||
throw new Error(`Invalid status: ${status}. Allowed values are ${GC.COMPLETED} and ${GC.CANCELLED}`);
|
||||
}
|
||||
const targetStatus = status as "COMPLETED" | "CANCELLED";
|
||||
|
||||
const existing = await db.getMaintenanceEventById(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Maintenance event with id ${id} does not exist`);
|
||||
}
|
||||
|
||||
return await db.updateMaintenanceEventStatus(id, status);
|
||||
const allowedFrom: string[] = targetStatus === GC.COMPLETED ? [GC.ONGOING] : [GC.SCHEDULED, GC.READY, GC.ONGOING];
|
||||
if (!allowedFrom.includes(existing.status)) {
|
||||
throw new Error(`Cannot transition event from ${existing.status} to ${targetStatus}`);
|
||||
}
|
||||
|
||||
if (existing.status === GC.ONGOING) {
|
||||
// Ended now, but never before its first minute nor after its planned end
|
||||
const endDateTime = Math.min(
|
||||
existing.end_date_time,
|
||||
Math.max(GetMinuteStartNowTimestampUTC(), existing.start_date_time + 60),
|
||||
);
|
||||
await db.updateMaintenanceEvent(id, { status: targetStatus, end_date_time: endDateTime });
|
||||
} else {
|
||||
await db.updateMaintenanceEventStatus(id, targetStatus);
|
||||
}
|
||||
|
||||
const updated = await db.getMaintenanceEventById(id);
|
||||
if (!updated) {
|
||||
throw new Error(`Maintenance event with id ${id} does not exist`);
|
||||
}
|
||||
|
||||
try {
|
||||
const siteData = await GetAllSiteData();
|
||||
const notificationSettings =
|
||||
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
|
||||
if (notificationSettings.event_types.ended) {
|
||||
const siteVars = siteDataToVariables(siteData);
|
||||
const siteUrl = siteVars.site_url;
|
||||
const maintenance = await db.getMaintenanceById(updated.maintenance_id);
|
||||
const monitors = await db.getMonitorsByMaintenanceId(updated.maintenance_id);
|
||||
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
|
||||
const eventDetailed: MaintenanceEventRecordDetailed = {
|
||||
...updated,
|
||||
title: maintenance?.title || "",
|
||||
description: maintenance?.description || null,
|
||||
};
|
||||
const update = maintenanceToVariables(
|
||||
eventDetailed,
|
||||
monitorNames,
|
||||
targetStatus === GC.COMPLETED ? "**has been completed**" : "**has been cancelled**",
|
||||
targetStatus === GC.COMPLETED ? "completed" : "cancelled",
|
||||
targetStatus === GC.COMPLETED ? "Maintenance Completed" : "Maintenance Cancelled",
|
||||
siteUrl,
|
||||
);
|
||||
await subscriberQueue.push(update);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error sending ${targetStatus} notification for maintenance event ${id}:`, err);
|
||||
}
|
||||
|
||||
return updated;
|
||||
};
|
||||
|
||||
export const DeleteMaintenanceEvent = async (id: number): Promise<number> => {
|
||||
@@ -534,20 +659,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 +685,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 +705,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 +726,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 +747,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);
|
||||
@@ -306,7 +328,8 @@ export async function DeleteMonitorAlertConfig(id: number): Promise<boolean> {
|
||||
throw new Error(`Monitor alert config with id '${id}' not found`);
|
||||
}
|
||||
|
||||
// Triggers will be deleted automatically due to CASCADE
|
||||
// The repository deletes trigger/monitor junctions and v2 alerts explicitly;
|
||||
// FK cascades are not enforced on SQLite
|
||||
const deleted = await db.deleteMonitorAlertConfig(id);
|
||||
return deleted > 0;
|
||||
}
|
||||
@@ -420,6 +443,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 +462,7 @@ export async function CreateMonitorAlertV2(
|
||||
|
||||
const insertData: MonitorAlertV2Insert = {
|
||||
config_id: configId,
|
||||
monitor_tag: monitorTag || null,
|
||||
incident_id: incidentId || null,
|
||||
alert_status: "TRIGGERED",
|
||||
};
|
||||
|
||||
@@ -21,11 +21,11 @@ import type {
|
||||
import type { MonitorFilter } from "../db/repositories/base.js";
|
||||
import db from "../db/db.js";
|
||||
import type { PaginationInput } from "../../types/common.js";
|
||||
import type { DayWiseStatus, NumberWithChange } from "../../types/monitor.js";
|
||||
import GC, { getBadgeStyle, type BadgeStyle } from "../../global-constants.js";
|
||||
import { makeBadge } from "badge-maker";
|
||||
import { ErrorSvg } from "../../anywhere.js";
|
||||
import { GetLastMonitoringValue, SetLastHeartbeat, DeleteMonitorCaches } from "../cache/setGet.js";
|
||||
import { CollapseStatusCounts } from "../../clientTools.js";
|
||||
import { translate, isLocaleAvailable } from "../i18n.js";
|
||||
import type { HeartbeatMonitor, GroupMonitorTypeData } from "../types/monitor.js";
|
||||
|
||||
@@ -92,6 +92,7 @@ interface MonitoringDataInput {
|
||||
latency?: number;
|
||||
type: string;
|
||||
error_message?: string | null;
|
||||
raw_status?: string | null;
|
||||
}
|
||||
|
||||
interface InterpolatedDataEntry {
|
||||
@@ -112,6 +113,7 @@ export const InsertMonitoringData = async (data: MonitoringDataInput): Promise<M
|
||||
latency: data.latency || 0,
|
||||
type: data.type,
|
||||
error_message: data.error_message,
|
||||
raw_status: data.raw_status,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -264,6 +266,7 @@ export const CloneMonitor = async ({ sourceTag, newTag, newName }: CloneMonitorI
|
||||
type_data: source.type_data,
|
||||
day_degraded_minimum_count: source.day_degraded_minimum_count,
|
||||
day_down_minimum_count: source.day_down_minimum_count,
|
||||
confirmation_threshold: source.confirmation_threshold,
|
||||
include_degraded_in_downtime: source.include_degraded_in_downtime,
|
||||
is_hidden: source.is_hidden,
|
||||
monitor_settings_json: source.monitor_settings_json,
|
||||
@@ -290,7 +293,7 @@ export const GetLatestMonitoringData = async (monitor_tag: string): Promise<Moni
|
||||
};
|
||||
export const GetLatestStatusActiveAll = async (): Promise<{ status: string }> => {
|
||||
//get all the active not hidden monitor tags
|
||||
const monitors = await db.getMonitors({ status: "ACTIVE", is_hidden: "NO" });
|
||||
const monitors = await db.getMonitors({ status: GC.ACTIVE, is_hidden: GC.NO });
|
||||
const monitor_tags = monitors.map((m) => m.tag);
|
||||
|
||||
const latestData: MonitoringData[] = [];
|
||||
@@ -302,19 +305,20 @@ export const GetLatestStatusActiveAll = async (): Promise<{ status: string }> =>
|
||||
}
|
||||
}
|
||||
|
||||
let status: string = GC.NO_DATA;
|
||||
for (let i = 0; i < latestData.length; i++) {
|
||||
//if any status is down then status = down, if any is degraded then status = degraded, down > degraded > up
|
||||
if (latestData[i].status === GC.DOWN) {
|
||||
status = GC.DOWN;
|
||||
} else if (latestData[i].status === GC.DEGRADED && status !== GC.DOWN) {
|
||||
status = GC.DEGRADED;
|
||||
} else if (latestData[i].status === GC.UP && status !== GC.DOWN && status !== GC.DEGRADED) {
|
||||
status = GC.UP;
|
||||
const counts = { countOfUp: 0, countOfDown: 0, countOfDegraded: 0, countOfMaintenance: 0 };
|
||||
for (const data of latestData) {
|
||||
if (data.status === GC.UP) {
|
||||
counts.countOfUp++;
|
||||
} else if (data.status === GC.DOWN) {
|
||||
counts.countOfDown++;
|
||||
} else if (data.status === GC.DEGRADED) {
|
||||
counts.countOfDegraded++;
|
||||
} else if (data.status === GC.MAINTENANCE) {
|
||||
counts.countOfMaintenance++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: status,
|
||||
status: CollapseStatusCounts(counts),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -399,9 +403,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,6 +423,7 @@ async function removeTagFromGroupMonitors(tag: string): Promise<void> {
|
||||
type_data: JSON.stringify(typeData),
|
||||
day_degraded_minimum_count: group.day_degraded_minimum_count,
|
||||
day_down_minimum_count: group.day_down_minimum_count,
|
||||
confirmation_threshold: group.confirmation_threshold,
|
||||
include_degraded_in_downtime: group.include_degraded_in_downtime,
|
||||
is_hidden: group.is_hidden,
|
||||
monitor_settings_json:
|
||||
@@ -438,6 +441,7 @@ export const DeleteMonitorCompletelyUsingTag = async (tag: string): Promise<numb
|
||||
await db.deleteMonitorDataByTag(tag);
|
||||
await db.deleteIncidentMonitorsByTag(tag);
|
||||
await db.deleteMonitorAlertsByTag(tag);
|
||||
await db.deleteMonitorAlertConfigsByMonitorTag(tag);
|
||||
await db.deletePageMonitorsByTag(tag);
|
||||
await db.deleteMaintenanceMonitorsByTag(tag);
|
||||
await removeTagFromGroupMonitors(tag);
|
||||
@@ -463,9 +467,6 @@ export const GetAllAlertsPaginated = async (
|
||||
export const GetMonitoringData = async (tag: string, since: number, now: number): Promise<MonitoringData[]> => {
|
||||
return await db.getMonitoringData(tag, since, now);
|
||||
};
|
||||
export const GetMonitoringDataAll = async (tags: string[], since: number, now: number): Promise<MonitoringData[]> => {
|
||||
return await db.getMonitoringDataAll(tags, since, now);
|
||||
};
|
||||
|
||||
export const InsertNewAlert = async (data: MonitorAlertInsert): Promise<MonitorAlert | undefined> => {
|
||||
if (await db.alertExists(data.monitor_tag, data.monitor_status, data.alert_status)) {
|
||||
@@ -549,7 +550,7 @@ export const GetBadge = async (badgeType: BadgeType, params: BadgeParams): Promi
|
||||
lastObj = await GetLatestStatusActiveAll();
|
||||
} else {
|
||||
// Single monitor status
|
||||
const monitors = await GetMonitorsParsed({ tag, status: "ACTIVE", is_hidden: "NO" });
|
||||
const monitors = await GetMonitorsParsed({ tag, status: GC.ACTIVE, is_hidden: GC.NO });
|
||||
if (monitors.length === 0) {
|
||||
return new Response(ErrorSvg, {
|
||||
headers: { "Content-Type": "image/svg+xml" },
|
||||
@@ -573,13 +574,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",
|
||||
@@ -640,14 +638,14 @@ export const GetBadge = async (badgeType: BadgeType, params: BadgeParams): Promi
|
||||
const siteData = await db.getSiteDataByKey("siteName");
|
||||
const siteName = siteData?.value as string | undefined;
|
||||
name = siteName || "All Monitors";
|
||||
const goodMonitors = await GetMonitorsParsed({ status: "ACTIVE", is_hidden: "NO" });
|
||||
const goodMonitors = await GetMonitorsParsed({ status: GC.ACTIVE, is_hidden: GC.NO });
|
||||
const activeTags = goodMonitors.map((monitor) => monitor.tag);
|
||||
|
||||
stats = await db.getStatusCountsByInterval(activeTags, since, now - since, 1);
|
||||
uptimeData = UptimeCalculator(stats);
|
||||
} else {
|
||||
// Single monitor badge
|
||||
const monitors = await GetMonitorsParsed({ tag });
|
||||
const monitors = await GetMonitorsParsed({ tag, status: GC.ACTIVE, is_hidden: GC.NO });
|
||||
if (monitors.length === 0) {
|
||||
return new Response(ErrorSvg, {
|
||||
headers: { "Content-Type": "image/svg+xml" },
|
||||
@@ -756,3 +754,6 @@ export const GetStatusCountsByIntervalGroupedByMonitor = async (
|
||||
await setCache(cacheKey, result, 60);
|
||||
return result;
|
||||
};
|
||||
export const GetLastKnownStatus = async (monitor_tag: string): Promise<MonitoringData | undefined> => {
|
||||
return await db.getLastKnownStatus(monitor_tag);
|
||||
};
|
||||
|
||||
@@ -107,6 +107,7 @@ export async function AddMonitorToPage(
|
||||
page_id: number,
|
||||
monitor_tag: string,
|
||||
monitor_settings_json?: string | null,
|
||||
position?: number,
|
||||
): Promise<void> {
|
||||
// Check if page exists
|
||||
const page = await db.getPageById(page_id);
|
||||
@@ -120,13 +121,38 @@ export async function AddMonitorToPage(
|
||||
throw new Error(`Monitor "${monitor_tag}" already exists on this page`);
|
||||
}
|
||||
|
||||
// If no position specified, append at end
|
||||
let finalPosition = position;
|
||||
if (finalPosition === undefined) {
|
||||
const existing = await db.getPageMonitors(page_id);
|
||||
finalPosition = existing.length > 0 ? Math.max(...existing.map((m) => m.position)) + 1 : 0;
|
||||
}
|
||||
|
||||
await db.addMonitorToPage({
|
||||
page_id,
|
||||
monitor_tag,
|
||||
monitor_settings_json: monitor_settings_json || null,
|
||||
position: finalPosition,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder monitors on a page
|
||||
*/
|
||||
export async function ReorderPageMonitors(page_id: number, monitor_tags: string[]): Promise<void> {
|
||||
const page = await db.getPageById(page_id);
|
||||
if (!page) {
|
||||
throw new Error(`Page with id ${page_id} not found`);
|
||||
}
|
||||
|
||||
const monitorPositions = monitor_tags.map((tag, index) => ({
|
||||
monitor_tag: tag,
|
||||
position: index,
|
||||
}));
|
||||
|
||||
await db.updatePageMonitorPositions(page_id, monitorPositions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove monitor from a page
|
||||
*/
|
||||
|
||||
@@ -17,7 +17,10 @@ import type {
|
||||
SiteNavItem,
|
||||
SiteStatusColors,
|
||||
SiteSubMenuOptions,
|
||||
SiteDateTimeFormat,
|
||||
SiteSubscriptionsSettings,
|
||||
SitemapXMLConfig,
|
||||
GlobalMaintenanceNotificationSettings,
|
||||
} from "../../types/site.js";
|
||||
|
||||
export interface SiteDataTransformed {
|
||||
@@ -60,6 +63,11 @@ export interface SiteDataTransformed {
|
||||
customCSS?: string;
|
||||
globalPageVisibilitySettings?: GlobalPageVisibilitySettings;
|
||||
pageOrderingSettings?: PageOrderingSettings;
|
||||
dateAndTimeFormat?: SiteDateTimeFormat;
|
||||
metaSiteTitle?: string;
|
||||
metaSiteDescription?: string;
|
||||
sitemap?: SitemapXMLConfig;
|
||||
globalMaintenanceNotificationSettings?: GlobalMaintenanceNotificationSettings;
|
||||
}
|
||||
|
||||
export function InsertKeyValue(key: string, value: string): Promise<number[]> {
|
||||
@@ -100,6 +108,22 @@ export const GetLocaleFromCookie = (site: SiteDataTransformed, cookies: Cookies)
|
||||
return selectedLang;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the site URL used for building absolute public URLs, without a trailing slash.
|
||||
* Prefers the configured siteURL and falls back to the ORIGIN env var; only absolute
|
||||
* http(s) values are returned. Returns an empty string when neither is usable, in which
|
||||
* case callers degrade to a relative path.
|
||||
*/
|
||||
export const GetSiteURL = async (): Promise<string> => {
|
||||
const siteURL = await GetSiteDataByKey("siteURL");
|
||||
for (const candidate of [siteURL, process.env.ORIGIN]) {
|
||||
if (typeof candidate === "string" && /^https?:\/\//i.test(candidate)) {
|
||||
return candidate.replace(/\/+$/, "");
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
export const GetSiteLogoURL = async (siteURL: string, logo: string, base: string): Promise<string> => {
|
||||
if (logo.startsWith("http")) {
|
||||
return logo;
|
||||
@@ -130,14 +154,17 @@ export const GetSiteDataByKey = async (key: string): Promise<unknown> => {
|
||||
return data.value;
|
||||
};
|
||||
|
||||
/** Checks the env vars required for setup, without touching the database. */
|
||||
export const HasRequiredEnv = (): boolean => {
|
||||
return (
|
||||
process.env.KENER_SECRET_KEY !== undefined &&
|
||||
process.env.ORIGIN !== undefined &&
|
||||
process.env.REDIS_URL !== undefined
|
||||
);
|
||||
};
|
||||
|
||||
export const IsSetupComplete = async (): Promise<boolean> => {
|
||||
if (process.env.KENER_SECRET_KEY === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (process.env.ORIGIN === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (process.env.REDIS_URL === undefined) {
|
||||
if (!HasRequiredEnv()) {
|
||||
return false;
|
||||
}
|
||||
let data = await db.getAllSiteData();
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
{
|
||||
@@ -271,4 +271,29 @@ export const siteDataKeys: SiteDataKey[] = [
|
||||
isValid: IsValidJSONString,
|
||||
data_type: "object",
|
||||
},
|
||||
{
|
||||
key: "dateAndTimeFormat",
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@ import db from "../db/db.js";
|
||||
import type { PaginationInput } from "$lib/types/common";
|
||||
import { GenerateToken, HashPassword, ValidatePassword, VerifyToken } from "./commonController.js";
|
||||
import type { Cookies } from "@sveltejs/kit";
|
||||
import type { UserRecordPublic, UserRecordDashboard } from "../types/db.js";
|
||||
import type { UserRecordPublic, UserRecordDashboard, RoleRecord } from "../types/db.js";
|
||||
import { GetAllSiteData } from "./controller.js";
|
||||
import { siteDataToVariables } from "../notification/notification_utils.js";
|
||||
import sendEmail from "../notification/email_notification.js";
|
||||
@@ -16,7 +16,7 @@ export interface UserUpdateInput {
|
||||
|
||||
interface ManualUserUpdateInput {
|
||||
updateType: string;
|
||||
role?: string;
|
||||
role_ids?: string[];
|
||||
is_active?: number;
|
||||
password?: string;
|
||||
passwordPlain?: string;
|
||||
@@ -32,7 +32,7 @@ interface NewUserInput {
|
||||
name: string;
|
||||
password: string;
|
||||
plainPassword: string;
|
||||
role: string;
|
||||
role_ids: string[];
|
||||
}
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
@@ -65,12 +65,18 @@ const validateNameOrThrow = (name: string): string => {
|
||||
return normalizedName;
|
||||
};
|
||||
|
||||
export const GetAllUsersPaginated = async (data: PaginationInput): Promise<UserRecordPublic[]> => {
|
||||
return await db.getUsersPaginated(data.page, data.limit);
|
||||
export const GetAllUsersPaginated = async (
|
||||
data: PaginationInput,
|
||||
filter?: { is_active?: number },
|
||||
): Promise<UserRecordPublic[]> => {
|
||||
return await db.getUsersPaginated(data.page, data.limit, filter);
|
||||
};
|
||||
|
||||
export const GetAllUsersPaginatedDashboard = async (data: PaginationInput): Promise<UserRecordDashboard[]> => {
|
||||
const users = await db.getUsersPaginated(data.page, data.limit);
|
||||
export const GetAllUsersPaginatedDashboard = async (
|
||||
data: PaginationInput,
|
||||
filter?: { is_active?: number },
|
||||
): Promise<UserRecordDashboard[]> => {
|
||||
const users = await db.getUsersPaginated(data.page, data.limit, filter);
|
||||
if (users.length === 0) return [];
|
||||
|
||||
// Batch fetch password statuses for all users
|
||||
@@ -88,8 +94,8 @@ export const GetAllUsers = async () => {
|
||||
return await db.getAllUsers();
|
||||
};
|
||||
|
||||
export const GetUsersCount = async () => {
|
||||
return await db.getUsersCount();
|
||||
export const GetUsersCount = async (filter?: { is_active?: number }) => {
|
||||
return await db.getTotalUsers(filter);
|
||||
};
|
||||
|
||||
export const GetUserPasswordHashById = async (id: number) => {
|
||||
@@ -145,14 +151,20 @@ export const UpdateUserData = async (data: UserUpdateInput): Promise<number> =>
|
||||
}
|
||||
};
|
||||
|
||||
export const CreateNewUser = async (currentUser: { role: string }, data: NewUserInput): Promise<number[]> => {
|
||||
let acceptedRoles = ["member", "editor"];
|
||||
if (!acceptedRoles.includes(data.role)) {
|
||||
throw new Error("Invalid role");
|
||||
export const CreateNewUser = async (data: NewUserInput): Promise<number[]> => {
|
||||
if (!data.role_ids || data.role_ids.length === 0) {
|
||||
throw new Error("At least one role is required");
|
||||
}
|
||||
|
||||
if (currentUser.role === "member") {
|
||||
throw new Error("Only admins and editors can create new users");
|
||||
// Validate all role_ids exist and are active
|
||||
for (const roleId of data.role_ids) {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" does not exist`);
|
||||
}
|
||||
if (role.status !== "ACTIVE") {
|
||||
throw new Error(`Role "${roleId}" is not active`);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedEmail = validateEmailOrThrow(data.email);
|
||||
@@ -163,11 +175,6 @@ export const CreateNewUser = async (currentUser: { role: string }, data: NewUser
|
||||
throw new Error("Password cannot be empty");
|
||||
}
|
||||
|
||||
//if data.role empty, throw error
|
||||
if (!!!data.role) {
|
||||
throw new Error("Role cannot be empty");
|
||||
}
|
||||
|
||||
//if data.password not equal to data.plainPassword, throw error
|
||||
if (data.password !== data.plainPassword) {
|
||||
throw new Error("Passwords do not match");
|
||||
@@ -182,7 +189,7 @@ export const CreateNewUser = async (currentUser: { role: string }, data: NewUser
|
||||
email: normalizedEmail,
|
||||
password_hash: await HashPassword(data.password),
|
||||
name: normalizedName,
|
||||
role: data.role,
|
||||
role_ids: data.role_ids,
|
||||
};
|
||||
return await db.insertUser(user);
|
||||
};
|
||||
@@ -202,7 +209,7 @@ export const CreateFirstUser = async (data: { email: string; name: string; passw
|
||||
email: normalizedEmail,
|
||||
password_hash: await HashPassword(data.password),
|
||||
name: normalizedName,
|
||||
role: "admin",
|
||||
role_ids: ["admin"],
|
||||
is_owner: "YES",
|
||||
};
|
||||
return await db.insertUser(user);
|
||||
@@ -229,33 +236,34 @@ export const UpdatePassword = async (data: PasswordUpdateInput): Promise<number>
|
||||
});
|
||||
};
|
||||
|
||||
const VALID_ROLES = ["admin", "editor", "member"] as const;
|
||||
|
||||
export const ManualUpdateUserData = async (
|
||||
byUser: { id: number; role: string; is_owner: string },
|
||||
forUserId: number,
|
||||
data: ManualUserUpdateInput,
|
||||
): Promise<number | undefined> => {
|
||||
export const ManualUpdateUserData = async (forUserId: number, data: ManualUserUpdateInput): Promise<number | void> => {
|
||||
let forUser = await db.getUserById(forUserId);
|
||||
if (!forUser) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
//only admins can update
|
||||
if (byUser.role !== "admin") {
|
||||
throw new Error("You do not have permission to update user");
|
||||
}
|
||||
// non-owner admins cannot modify other admins (self-updates are allowed)
|
||||
if (forUser.role === "admin" && byUser.is_owner !== "YES" && forUser.id !== byUser.id) {
|
||||
throw new Error("Only the owner can modify other admins");
|
||||
}
|
||||
if (data.updateType == "role") {
|
||||
if (!data.role) throw new Error("Role is required");
|
||||
if (!VALID_ROLES.includes(data.role as (typeof VALID_ROLES)[number])) {
|
||||
throw new Error(`Invalid role. Must be one of: ${VALID_ROLES.join(", ")}`);
|
||||
if (!data.role_ids || data.role_ids.length === 0) throw new Error("At least one role is required");
|
||||
// Owner must always retain the admin role
|
||||
if (forUser.is_owner === "YES" && !data.role_ids.includes("admin")) {
|
||||
throw new Error("Owner must retain the admin role");
|
||||
}
|
||||
return await db.updateUserRole(forUser.id, data.role);
|
||||
// Validate all role_ids exist and are active
|
||||
for (const roleId of data.role_ids) {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" does not exist`);
|
||||
}
|
||||
if (role.status !== "ACTIVE") {
|
||||
throw new Error(`Role "${roleId}" is not active`);
|
||||
}
|
||||
}
|
||||
return await db.updateUserRoles(forUser.id, data.role_ids);
|
||||
} else if (data.updateType == "is_active") {
|
||||
if (data.is_active === undefined) throw new Error("is_active is required");
|
||||
// Owner cannot be deactivated
|
||||
if (forUser.is_owner === "YES" && data.is_active === 0) {
|
||||
throw new Error("Owner account cannot be deactivated");
|
||||
}
|
||||
return await db.updateUserIsActive(forUser.id, data.is_active);
|
||||
} else if (data.updateType == "password") {
|
||||
if (!data.password || !data.passwordPlain) throw new Error("Password is required");
|
||||
@@ -297,15 +305,20 @@ export const GetTotalUserPages = async (limit: number): Promise<number> => {
|
||||
};
|
||||
|
||||
//send invitation email to user for account creation
|
||||
export const SendInvitationEmail = async (email: string, role: string, name: string, currentUserRole: string) => {
|
||||
if (currentUserRole === "member") {
|
||||
throw new Error("Only admins and editors can create new users");
|
||||
export const SendInvitationEmail = async (email: string, role_ids: string[], name: string) => {
|
||||
if (!role_ids || role_ids.length === 0) {
|
||||
throw new Error("At least one role is required");
|
||||
}
|
||||
|
||||
// Admins can add admin, editor, member; Editors can only add editor, member
|
||||
const acceptedRoles = currentUserRole === "admin" ? ["admin", "editor", "member"] : ["editor", "member"];
|
||||
if (!acceptedRoles.includes(role)) {
|
||||
throw new Error("Invalid role");
|
||||
// Validate all role_ids exist and are active
|
||||
for (const roleId of role_ids) {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" does not exist`);
|
||||
}
|
||||
if (role.status !== "ACTIVE") {
|
||||
throw new Error(`Role "${roleId}" is not active`);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedEmail = validateEmailOrThrow(email);
|
||||
@@ -323,7 +336,7 @@ export const SendInvitationEmail = async (email: string, role: string, name: str
|
||||
email: normalizedEmail,
|
||||
password_hash: "",
|
||||
name: normalizedName,
|
||||
role,
|
||||
role_ids: role_ids,
|
||||
is_active: 0,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
@@ -364,11 +377,7 @@ export const SendInvitationEmail = async (email: string, role: string, name: str
|
||||
};
|
||||
|
||||
//resend invitation email to existing user with blank password
|
||||
export const ResendInvitationEmail = async (email: string, currentUserRole: string) => {
|
||||
if (currentUserRole === "member") {
|
||||
throw new Error("Only admins and editors can resend invitations");
|
||||
}
|
||||
|
||||
export const ResendInvitationEmail = async (email: string) => {
|
||||
const normalizedEmail = validateEmailOrThrow(email);
|
||||
|
||||
const user = await db.getUserByEmail(normalizedEmail);
|
||||
@@ -410,17 +419,11 @@ export const ResendInvitationEmail = async (email: string, currentUserRole: stri
|
||||
};
|
||||
|
||||
// send verification email with verification link
|
||||
export const SendVerificationEmail = async (toUserId: number, currentUser: { id: number; role: string }) => {
|
||||
export const SendVerificationEmail = async (toUserId: number, currentUserId: number) => {
|
||||
if (!toUserId) {
|
||||
throw new Error("User ID is required");
|
||||
}
|
||||
|
||||
// Only admins/editors can send verification to other users.
|
||||
// Members can only send verification email to themselves.
|
||||
if (currentUser.role === "member" && currentUser.id !== toUserId) {
|
||||
throw new Error("You do not have permission to send verification email for this user");
|
||||
}
|
||||
|
||||
const user = await db.getUserById(toUserId);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
@@ -458,3 +461,221 @@ export const SendVerificationEmail = async (toUserId: number, currentUser: { id:
|
||||
template.template_text_body || "",
|
||||
);
|
||||
};
|
||||
|
||||
const RESTRICTED_ROLE_IDS = ["admin", "editor", "member"];
|
||||
const ROLE_ID_REGEX = /^[a-z0-9_-]+$/;
|
||||
|
||||
const normalizeRoleId = (id: string): string => {
|
||||
return id.trim().toLowerCase().replace(/\s+/g, "_");
|
||||
};
|
||||
|
||||
export const CreateRole = async (data: { role_id: string; name: string }): Promise<RoleRecord> => {
|
||||
const roleId = normalizeRoleId(data.role_id || "");
|
||||
const roleName = data.name?.trim();
|
||||
|
||||
if (!roleId) {
|
||||
throw new Error("Role ID is required");
|
||||
}
|
||||
if (!ROLE_ID_REGEX.test(roleId)) {
|
||||
throw new Error("Role ID can only contain lowercase letters, numbers, underscores, and hyphens");
|
||||
}
|
||||
if (!roleName) {
|
||||
throw new Error("Role name is required");
|
||||
}
|
||||
|
||||
if (RESTRICTED_ROLE_IDS.includes(roleId)) {
|
||||
throw new Error(`Role ID "${roleId}" is restricted and cannot be used`);
|
||||
}
|
||||
|
||||
const existing = await db.getRoleById(roleId);
|
||||
if (existing) {
|
||||
throw new Error(`Role with ID "${roleId}" already exists`);
|
||||
}
|
||||
|
||||
await db.insertRole({ id: roleId, role_name: roleName });
|
||||
|
||||
const created = await db.getRoleById(roleId);
|
||||
if (!created) {
|
||||
throw new Error("Failed to create role");
|
||||
}
|
||||
return created;
|
||||
};
|
||||
|
||||
export const UpdateRole = async (roleId: string, data: { name?: string; status?: string }): Promise<RoleRecord> => {
|
||||
if (!roleId) {
|
||||
throw new Error("Role ID is required");
|
||||
}
|
||||
|
||||
const existing = await db.getRoleById(roleId);
|
||||
if (!existing) {
|
||||
throw new Error(`Role "${roleId}" not found`);
|
||||
}
|
||||
|
||||
if (existing.readonly === 1) {
|
||||
throw new Error("Readonly roles cannot be updated");
|
||||
}
|
||||
|
||||
const updates: { role_name?: string; status?: string } = {};
|
||||
|
||||
if (data.name !== undefined) {
|
||||
const trimmed = data.name.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Role name cannot be empty");
|
||||
}
|
||||
updates.role_name = trimmed;
|
||||
}
|
||||
|
||||
if (data.status !== undefined) {
|
||||
if (data.status !== "ACTIVE" && data.status !== "INACTIVE") {
|
||||
throw new Error("Status must be ACTIVE or INACTIVE");
|
||||
}
|
||||
updates.status = data.status;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
throw new Error("No valid fields to update");
|
||||
}
|
||||
|
||||
await db.updateRole(roleId, updates);
|
||||
|
||||
const updated = await db.getRoleById(roleId);
|
||||
if (!updated) {
|
||||
throw new Error("Failed to retrieve updated role");
|
||||
}
|
||||
return updated;
|
||||
};
|
||||
|
||||
export const DeleteRole = async (
|
||||
roleId: string,
|
||||
options: { action: "migrate"; targetRoleId: string } | { action: "remove" },
|
||||
): Promise<{ success: true }> => {
|
||||
if (!roleId) {
|
||||
throw new Error("Role ID is required");
|
||||
}
|
||||
|
||||
const existing = await db.getRoleById(roleId);
|
||||
if (!existing) {
|
||||
throw new Error(`Role "${roleId}" not found`);
|
||||
}
|
||||
|
||||
if (existing.readonly === 1) {
|
||||
throw new Error("Readonly roles cannot be deleted");
|
||||
}
|
||||
|
||||
if (options.action === "migrate") {
|
||||
const targetRoleId = options.targetRoleId?.trim();
|
||||
if (!targetRoleId) {
|
||||
throw new Error("Target role ID is required for migration");
|
||||
}
|
||||
if (targetRoleId === roleId) {
|
||||
throw new Error("Target role cannot be the same as the role being deleted");
|
||||
}
|
||||
const targetRole = await db.getRoleById(targetRoleId);
|
||||
if (!targetRole) {
|
||||
throw new Error(`Target role "${targetRoleId}" not found`);
|
||||
}
|
||||
if (targetRole.status !== "ACTIVE") {
|
||||
throw new Error("Cannot migrate users to an inactive role");
|
||||
}
|
||||
await db.migrateUsersRole(roleId, targetRoleId);
|
||||
}
|
||||
|
||||
// CASCADE on FK will clean up users_roles and roles_permissions
|
||||
await db.deleteRole(roleId);
|
||||
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const GetAllRoles = async (): Promise<RoleRecord[]> => {
|
||||
return await db.getAllRoles();
|
||||
};
|
||||
|
||||
export const GetAllPermissions = async () => {
|
||||
return await db.getAllPermissions();
|
||||
};
|
||||
|
||||
export const GetRolePermissions = async (roleId: string) => {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" not found`);
|
||||
}
|
||||
return await db.getRolePermissions(roleId);
|
||||
};
|
||||
|
||||
export const UpdateRolePermissions = async (roleId: string, permissionIds: string[]) => {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" not found`);
|
||||
}
|
||||
if (role.readonly === 1) {
|
||||
throw new Error("Readonly roles cannot have their permissions modified");
|
||||
}
|
||||
|
||||
// Get current permissions
|
||||
const current = await db.getRolePermissions(roleId);
|
||||
const currentIds = new Set(current.map((p) => p.permissions_id));
|
||||
const desiredIds = new Set(permissionIds);
|
||||
|
||||
// Add new permissions
|
||||
for (const pid of permissionIds) {
|
||||
if (!currentIds.has(pid)) {
|
||||
await db.addRolePermission(roleId, pid);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old permissions
|
||||
for (const pid of currentIds) {
|
||||
if (!desiredIds.has(pid)) {
|
||||
await db.removeRolePermission(roleId, pid);
|
||||
}
|
||||
}
|
||||
|
||||
return await db.getRolePermissions(roleId);
|
||||
};
|
||||
|
||||
export const GetRoleUsers = async (roleId: string) => {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" not found`);
|
||||
}
|
||||
return await db.getUsersByRoleId(roleId);
|
||||
};
|
||||
|
||||
export const AddUserToRole = async (roleId: string, userId: number) => {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" not found`);
|
||||
}
|
||||
if (role.status !== "ACTIVE") {
|
||||
throw new Error(`Role "${roleId}" is not active`);
|
||||
}
|
||||
// Check if user already in role
|
||||
const users = await db.getUsersByRoleId(roleId);
|
||||
if (users.some((u) => u.id === userId)) {
|
||||
throw new Error("User is already assigned to this role");
|
||||
}
|
||||
await db.addUserToRole(roleId, userId);
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const RemoveUserFromRole = async (roleId: string, userId: number) => {
|
||||
if (roleId === "admin") {
|
||||
const user = await db.getUserById(userId);
|
||||
if (user && user.is_owner === "YES") {
|
||||
throw new Error("The owner cannot be removed from the admin role");
|
||||
}
|
||||
}
|
||||
await db.removeUserFromRole(roleId, userId);
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const GetUserPermissions = async (userId: number): Promise<Set<string>> => {
|
||||
const permissionIds = await db.getUserPermissionIds(userId);
|
||||
return new Set(permissionIds);
|
||||
};
|
||||
|
||||
export const RequirePermission = (userPermissions: Set<string>, permissionId: string): void => {
|
||||
if (!userPermissions.has(permissionId)) {
|
||||
throw new Error("You do not have permission to perform this action");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DbImpl from "./dbimpl";
|
||||
import knexOb from "../../../../knexfile.js";
|
||||
import knexOb, { workerKnexOb } from "../../../../knexfile.js";
|
||||
|
||||
const instance: DbImpl = new DbImpl(knexOb);
|
||||
const instance: DbImpl = new DbImpl(knexOb, workerKnexOb);
|
||||
export default instance;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Knex from "knex";
|
||||
import type { Knex as KnexType } from "knex";
|
||||
import { runWithWorkerKnex } from "./poolContext.js";
|
||||
|
||||
// Import all repositories
|
||||
import { MonitoringRepository } from "./repositories/monitoring.js";
|
||||
@@ -29,6 +30,9 @@ export type * from "../types/db.js";
|
||||
*/
|
||||
class DbImpl {
|
||||
private knex: KnexType;
|
||||
// Dedicated pool for background jobs (Postgres/MySQL). Equals `knex` when
|
||||
// there is no separate worker pool (e.g. SQLite).
|
||||
private workerKnex: KnexType;
|
||||
|
||||
// Domain repositories
|
||||
private monitoring!: MonitoringRepository;
|
||||
@@ -48,7 +52,6 @@ class DbImpl {
|
||||
// ============ Monitoring Data ============
|
||||
insertMonitoringData!: MonitoringRepository["insertMonitoringData"];
|
||||
getMonitoringData!: MonitoringRepository["getMonitoringData"];
|
||||
getMonitoringDataAll!: MonitoringRepository["getMonitoringDataAll"];
|
||||
getLatestMonitoringData!: MonitoringRepository["getLatestMonitoringData"];
|
||||
getLatestMonitoringDataN!: MonitoringRepository["getLatestMonitoringDataN"];
|
||||
getMonitoringDataPaginated!: MonitoringRepository["getMonitoringDataPaginated"];
|
||||
@@ -65,11 +68,15 @@ class DbImpl {
|
||||
consecutivelyStatusFor!: MonitoringRepository["consecutivelyStatusFor"];
|
||||
consecutivelyLatencyGreaterThan!: MonitoringRepository["consecutivelyLatencyGreaterThan"];
|
||||
consecutivelyLatencyLessThan!: MonitoringRepository["consecutivelyLatencyLessThan"];
|
||||
getRecentSamplesForConfirmation!: MonitoringRepository["getRecentSamplesForConfirmation"];
|
||||
getLastObservedStatus!: MonitoringRepository["getLastObservedStatus"];
|
||||
backfillConfirmedStatus!: MonitoringRepository["backfillConfirmedStatus"];
|
||||
updateMonitoringData!: MonitoringRepository["updateMonitoringData"];
|
||||
deleteMonitorDataByTag!: MonitoringRepository["deleteMonitorDataByTag"];
|
||||
getStatusCountsByInterval!: MonitoringRepository["getStatusCountsByInterval"];
|
||||
getStatusCountsByIntervalGroupedByMonitor!: MonitoringRepository["getStatusCountsByIntervalGroupedByMonitor"];
|
||||
getStatusCountsForLastN!: MonitoringRepository["getStatusCountsForLastN"];
|
||||
getLastKnownStatus!: MonitoringRepository["getLastKnownStatus"];
|
||||
|
||||
// ============ Monitors ============
|
||||
getMonitorsByTags!: MonitorsRepository["getMonitorsByTags"];
|
||||
@@ -115,11 +122,29 @@ class DbImpl {
|
||||
getUsersPaginated!: UsersRepository["getUsersPaginated"];
|
||||
getTotalUsers!: UsersRepository["getTotalUsers"];
|
||||
updateUserName!: UsersRepository["updateUserName"];
|
||||
updateUserRole!: UsersRepository["updateUserRole"];
|
||||
updateUserRoles!: UsersRepository["updateUserRoles"];
|
||||
updateUserIsActive!: UsersRepository["updateUserIsActive"];
|
||||
updateUserPasswordById!: UsersRepository["updateUserPasswordById"];
|
||||
updateIsVerified!: UsersRepository["updateIsVerified"];
|
||||
|
||||
// ============ Roles ============
|
||||
getRoleById!: UsersRepository["getRoleById"];
|
||||
getAllRoles!: UsersRepository["getAllRoles"];
|
||||
insertRole!: UsersRepository["insertRole"];
|
||||
updateRole!: UsersRepository["updateRole"];
|
||||
deleteRole!: UsersRepository["deleteRole"];
|
||||
getUsersCountByRoleId!: UsersRepository["getUsersCountByRoleId"];
|
||||
migrateUsersRole!: UsersRepository["migrateUsersRole"];
|
||||
getRolePermissions!: UsersRepository["getRolePermissions"];
|
||||
getAllPermissions!: UsersRepository["getAllPermissions"];
|
||||
addRolePermission!: UsersRepository["addRolePermission"];
|
||||
removeRolePermission!: UsersRepository["removeRolePermission"];
|
||||
getUsersByRoleId!: UsersRepository["getUsersByRoleId"];
|
||||
addUserToRole!: UsersRepository["addUserToRole"];
|
||||
removeUserFromRole!: UsersRepository["removeUserFromRole"];
|
||||
getUserPermissionIds!: UsersRepository["getUserPermissionIds"];
|
||||
getUserRoleIds!: UsersRepository["getUserRoleIds"];
|
||||
|
||||
// ============ API Keys ============
|
||||
createNewApiKey!: UsersRepository["createNewApiKey"];
|
||||
updateApiKeyStatus!: UsersRepository["updateApiKeyStatus"];
|
||||
@@ -187,6 +212,7 @@ class DbImpl {
|
||||
updateIncidentCommentByID!: IncidentsRepository["updateIncidentCommentByID"];
|
||||
updateIncidentCommentStatusByID!: IncidentsRepository["updateIncidentCommentStatusByID"];
|
||||
getIncidentCommentByID!: IncidentsRepository["getIncidentCommentByID"];
|
||||
deleteIncidentCommentsByIncidentID!: IncidentsRepository["deleteIncidentCommentsByIncidentID"];
|
||||
|
||||
// ============ Images ============
|
||||
insertImage!: ImagesRepository["insertImage"];
|
||||
@@ -212,6 +238,7 @@ class DbImpl {
|
||||
monitorExistsOnPage!: PagesRepository["monitorExistsOnPage"];
|
||||
deletePageMonitorsByTag!: PagesRepository["deletePageMonitorsByTag"];
|
||||
deletePageMonitorsByPageId!: PagesRepository["deletePageMonitorsByPageId"];
|
||||
updatePageMonitorPositions!: PagesRepository["updatePageMonitorPositions"];
|
||||
|
||||
// ============ Maintenances ============
|
||||
createMaintenance!: MaintenancesRepository["createMaintenance"];
|
||||
@@ -290,6 +317,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"];
|
||||
@@ -345,8 +378,11 @@ class DbImpl {
|
||||
deleteEmailTemplate!: EmailTemplateConfigRepository["deleteEmailTemplate"];
|
||||
upsertEmailTemplate!: EmailTemplateConfigRepository["upsertEmailTemplate"];
|
||||
|
||||
constructor(opts: KnexType.Config) {
|
||||
constructor(opts: KnexType.Config, workerOpts?: KnexType.Config | null) {
|
||||
this.knex = Knex(opts);
|
||||
// Separate pool for background jobs when configured (Postgres/MySQL);
|
||||
// otherwise reuse the web pool (SQLite has a single connection).
|
||||
this.workerKnex = workerOpts ? Knex(workerOpts) : this.knex;
|
||||
|
||||
// Initialize repositories
|
||||
this.monitoring = new MonitoringRepository(this.knex);
|
||||
@@ -382,7 +418,6 @@ class DbImpl {
|
||||
private bindMonitoringMethods(): void {
|
||||
this.insertMonitoringData = this.monitoring.insertMonitoringData.bind(this.monitoring);
|
||||
this.getMonitoringData = this.monitoring.getMonitoringData.bind(this.monitoring);
|
||||
this.getMonitoringDataAll = this.monitoring.getMonitoringDataAll.bind(this.monitoring);
|
||||
this.getLatestMonitoringData = this.monitoring.getLatestMonitoringData.bind(this.monitoring);
|
||||
this.getLatestMonitoringDataN = this.monitoring.getLatestMonitoringDataN.bind(this.monitoring);
|
||||
this.getMonitoringDataPaginated = this.monitoring.getMonitoringDataPaginated.bind(this.monitoring);
|
||||
@@ -399,6 +434,9 @@ class DbImpl {
|
||||
this.consecutivelyStatusFor = this.monitoring.consecutivelyStatusFor.bind(this.monitoring);
|
||||
this.consecutivelyLatencyGreaterThan = this.monitoring.consecutivelyLatencyGreaterThan.bind(this.monitoring);
|
||||
this.consecutivelyLatencyLessThan = this.monitoring.consecutivelyLatencyLessThan.bind(this.monitoring);
|
||||
this.getRecentSamplesForConfirmation = this.monitoring.getRecentSamplesForConfirmation.bind(this.monitoring);
|
||||
this.getLastObservedStatus = this.monitoring.getLastObservedStatus.bind(this.monitoring);
|
||||
this.backfillConfirmedStatus = this.monitoring.backfillConfirmedStatus.bind(this.monitoring);
|
||||
this.updateMonitoringData = this.monitoring.updateMonitoringData.bind(this.monitoring);
|
||||
this.deleteMonitorDataByTag = this.monitoring.deleteMonitorDataByTag.bind(this.monitoring);
|
||||
this.getStatusCountsByInterval = this.monitoring.getStatusCountsByInterval.bind(this.monitoring);
|
||||
@@ -406,6 +444,7 @@ class DbImpl {
|
||||
this.monitoring,
|
||||
);
|
||||
this.getStatusCountsForLastN = this.monitoring.getStatusCountsForLastN.bind(this.monitoring);
|
||||
this.getLastKnownStatus = this.monitoring.getLastKnownStatus.bind(this.monitoring);
|
||||
}
|
||||
|
||||
private bindMonitorsMethods(): void {
|
||||
@@ -452,7 +491,7 @@ class DbImpl {
|
||||
this.getUsersPaginated = this.users.getUsersPaginated.bind(this.users);
|
||||
this.getTotalUsers = this.users.getTotalUsers.bind(this.users);
|
||||
this.updateUserName = this.users.updateUserName.bind(this.users);
|
||||
this.updateUserRole = this.users.updateUserRole.bind(this.users);
|
||||
this.updateUserRoles = this.users.updateUserRoles.bind(this.users);
|
||||
this.updateUserIsActive = this.users.updateUserIsActive.bind(this.users);
|
||||
this.updateUserPasswordById = this.users.updateUserPasswordById.bind(this.users);
|
||||
this.updateIsVerified = this.users.updateIsVerified.bind(this.users);
|
||||
@@ -461,6 +500,24 @@ class DbImpl {
|
||||
this.deleteApiKey = this.users.deleteApiKey.bind(this.users);
|
||||
this.getApiKeyByHashedKey = this.users.getApiKeyByHashedKey.bind(this.users);
|
||||
this.getAllApiKeys = this.users.getAllApiKeys.bind(this.users);
|
||||
|
||||
// Roles
|
||||
this.getRoleById = this.users.getRoleById.bind(this.users);
|
||||
this.getAllRoles = this.users.getAllRoles.bind(this.users);
|
||||
this.insertRole = this.users.insertRole.bind(this.users);
|
||||
this.updateRole = this.users.updateRole.bind(this.users);
|
||||
this.deleteRole = this.users.deleteRole.bind(this.users);
|
||||
this.getUsersCountByRoleId = this.users.getUsersCountByRoleId.bind(this.users);
|
||||
this.migrateUsersRole = this.users.migrateUsersRole.bind(this.users);
|
||||
this.getRolePermissions = this.users.getRolePermissions.bind(this.users);
|
||||
this.getAllPermissions = this.users.getAllPermissions.bind(this.users);
|
||||
this.addRolePermission = this.users.addRolePermission.bind(this.users);
|
||||
this.removeRolePermission = this.users.removeRolePermission.bind(this.users);
|
||||
this.getUsersByRoleId = this.users.getUsersByRoleId.bind(this.users);
|
||||
this.addUserToRole = this.users.addUserToRole.bind(this.users);
|
||||
this.removeUserFromRole = this.users.removeUserFromRole.bind(this.users);
|
||||
this.getUserPermissionIds = this.users.getUserPermissionIds.bind(this.users);
|
||||
this.getUserRoleIds = this.users.getUserRoleIds.bind(this.users);
|
||||
}
|
||||
|
||||
private bindSiteDataMethods(): void {
|
||||
@@ -529,6 +586,7 @@ class DbImpl {
|
||||
this.updateIncidentCommentByID = this.incidents.updateIncidentCommentByID.bind(this.incidents);
|
||||
this.updateIncidentCommentStatusByID = this.incidents.updateIncidentCommentStatusByID.bind(this.incidents);
|
||||
this.getIncidentCommentByID = this.incidents.getIncidentCommentByID.bind(this.incidents);
|
||||
this.deleteIncidentCommentsByIncidentID = this.incidents.deleteIncidentCommentsByIncidentID.bind(this.incidents);
|
||||
}
|
||||
|
||||
private bindImagesMethods(): void {
|
||||
@@ -554,6 +612,7 @@ class DbImpl {
|
||||
this.monitorExistsOnPage = this.pages.monitorExistsOnPage.bind(this.pages);
|
||||
this.deletePageMonitorsByTag = this.pages.deletePageMonitorsByTag.bind(this.pages);
|
||||
this.deletePageMonitorsByPageId = this.pages.deletePageMonitorsByPageId.bind(this.pages);
|
||||
this.updatePageMonitorPositions = this.pages.updatePageMonitorPositions.bind(this.pages);
|
||||
}
|
||||
|
||||
private bindMaintenancesMethods(): void {
|
||||
@@ -688,6 +747,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);
|
||||
@@ -780,8 +847,30 @@ class DbImpl {
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
/**
|
||||
* Runs `fn` with all repository queries routed to the worker connection pool.
|
||||
* Wrap background work (BullMQ job processors, schedulers) with this so a
|
||||
* burst of jobs cannot exhaust the web pool that serves page loads.
|
||||
*/
|
||||
runInWorkerContext<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return runWithWorkerKnex(this.workerKnex, fn);
|
||||
}
|
||||
|
||||
/** Probes database connectivity with a trivial query. Never throws. */
|
||||
async ping(): Promise<boolean> {
|
||||
try {
|
||||
await this.knex.raw("select 1");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
return await this.knex.destroy();
|
||||
await this.knex.destroy();
|
||||
if (this.workerKnex !== this.knex) {
|
||||
await this.workerKnex.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import type { Knex as KnexType } from "knex";
|
||||
|
||||
// Per-execution-context selection of the database connection pool.
|
||||
//
|
||||
// Kener runs SvelteKit requests, the cron scheduler, and the BullMQ workers in
|
||||
// a single process, all sharing one Knex instance. A burst of background jobs
|
||||
// could therefore exhaust the connection pool and time out user-facing page
|
||||
// loads (KnexTimeoutError on acquire). To prevent that, background work runs
|
||||
// against a dedicated worker pool: queues/q.ts wraps every job processor in
|
||||
// runWithWorkerKnex(), and BaseRepository reads getWorkerKnex() so its queries
|
||||
// route to that pool. Anything outside a job (requests, startup, migrations)
|
||||
// has no store set and falls back to the web pool.
|
||||
//
|
||||
// See knexfile.ts for pool sizing and docs .../setup/database-setup.md.
|
||||
const workerKnexStorage = new AsyncLocalStorage<KnexType>();
|
||||
|
||||
/** Runs `fn` with all repository queries routed to the worker pool `knex`. */
|
||||
export function runWithWorkerKnex<T>(knex: KnexType, fn: () => Promise<T>): Promise<T> {
|
||||
return workerKnexStorage.run(knex, fn);
|
||||
}
|
||||
|
||||
/** The worker pool for the current context, or undefined when not in a job. */
|
||||
export function getWorkerKnex(): KnexType | undefined {
|
||||
return workerKnexStorage.getStore();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Knex as KnexType } from "knex";
|
||||
import { getWorkerKnex } from "../poolContext.js";
|
||||
|
||||
// Filter types for queries
|
||||
export interface MonitorFilter {
|
||||
@@ -35,9 +36,22 @@ export interface CountResult {
|
||||
* Base repository class that provides access to the Knex instance
|
||||
*/
|
||||
export abstract class BaseRepository {
|
||||
protected knex: KnexType;
|
||||
private readonly fallbackKnex: KnexType;
|
||||
|
||||
constructor(knex: KnexType) {
|
||||
this.knex = knex;
|
||||
this.fallbackKnex = knex;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Knex instance for the current execution context.
|
||||
*
|
||||
* Background jobs run inside a worker-pool context (set in queues/q.ts), so
|
||||
* their queries use the dedicated worker connection pool. Everything else —
|
||||
* SvelteKit requests, startup — falls back to the web pool this repository
|
||||
* was constructed with. This keeps a burst of background jobs from exhausting
|
||||
* the connections that serve page loads. See poolContext.ts and knexfile.ts.
|
||||
*/
|
||||
protected get knex(): KnexType {
|
||||
return getWorkerKnex() ?? this.fallbackKnex;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -818,6 +818,10 @@ export class IncidentsRepository extends BaseRepository {
|
||||
return await this.knex("incident_comments").where({ id }).first();
|
||||
}
|
||||
|
||||
async deleteIncidentCommentsByIncidentID(incident_id: number): Promise<number> {
|
||||
return await this.knex("incident_comments").where({ incident_id }).del();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get incidents within a date range for the events page
|
||||
* Returns incidents that started within the given date range
|
||||
|
||||
@@ -483,6 +483,7 @@ export class MaintenancesRepository extends BaseRepository {
|
||||
"maintenances_events.created_at",
|
||||
"maintenances_events.updated_at",
|
||||
"maintenances_events.status as status",
|
||||
"maintenances.is_global as is_global",
|
||||
)
|
||||
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
|
||||
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
|
||||
@@ -516,6 +517,7 @@ export class MaintenancesRepository extends BaseRepository {
|
||||
"maintenances_events.created_at",
|
||||
"maintenances_events.updated_at",
|
||||
"maintenances_events.status as status",
|
||||
"maintenances.is_global as is_global",
|
||||
)
|
||||
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
|
||||
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
|
||||
@@ -564,6 +566,7 @@ export class MaintenancesRepository extends BaseRepository {
|
||||
"monitors.is_hidden as monitor_is_hidden",
|
||||
"maintenances_events.created_at",
|
||||
"maintenances_events.updated_at",
|
||||
"maintenances.is_global as is_global",
|
||||
)
|
||||
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
|
||||
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
|
||||
@@ -606,6 +609,7 @@ export class MaintenancesRepository extends BaseRepository {
|
||||
"maintenances_events.created_at",
|
||||
"maintenances_events.updated_at",
|
||||
"maintenances_events.status as status",
|
||||
"maintenances.is_global as is_global",
|
||||
)
|
||||
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
|
||||
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
|
||||
@@ -648,6 +652,7 @@ export class MaintenancesRepository extends BaseRepository {
|
||||
"maintenances_events.created_at",
|
||||
"maintenances_events.updated_at",
|
||||
"maintenances_events.status as status",
|
||||
"maintenances.is_global as is_global",
|
||||
)
|
||||
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
|
||||
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
|
||||
@@ -686,6 +691,7 @@ export class MaintenancesRepository extends BaseRepository {
|
||||
"maintenances_events.created_at",
|
||||
"maintenances_events.updated_at",
|
||||
"maintenances_events.status as status",
|
||||
"maintenances.is_global as is_global",
|
||||
)
|
||||
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
|
||||
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
|
||||
@@ -714,6 +720,7 @@ export class MaintenancesRepository extends BaseRepository {
|
||||
start_date_time: row.start_date_time,
|
||||
end_date_time: row.end_date_time,
|
||||
status: row.status,
|
||||
is_global: row.is_global,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
monitors: [],
|
||||
|
||||
@@ -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,15 +132,26 @@ 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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a monitor alert config by ID
|
||||
* Delete a monitor alert config by ID, including all child rows.
|
||||
*
|
||||
* Child rows are removed explicitly even though FK cascades are declared:
|
||||
* SQLite never enforces them (foreign_keys pragma is off), so relying on
|
||||
* CASCADE orphans children on the default deployment. See
|
||||
* docs/adr/0008-explicit-deletes-over-fk-cascades.md.
|
||||
*/
|
||||
async deleteMonitorAlertConfig(id: number): Promise<number> {
|
||||
await this.knex("monitor_alerts_v2").where({ config_id: id }).del();
|
||||
await this.knex("monitor_alerts_config_triggers").where({ monitor_alerts_id: id }).del();
|
||||
await this.knex("monitor_alerts_config_monitors").where({ monitor_alerts_id: id }).del();
|
||||
return await this.knex("monitor_alerts_config").where({ id }).del();
|
||||
}
|
||||
|
||||
@@ -140,26 +159,55 @@ 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, along with its per-monitor
|
||||
// alert state — a shared config survives the detach, but its v2 rows for
|
||||
// this tag would otherwise dangle (see deleteMonitorAlertConfig on why
|
||||
// FK cascades can't be relied on)
|
||||
await this.knex("monitor_alerts_v2").where({ monitor_tag: monitorTag }).del();
|
||||
await this.knex("monitor_alerts_config_monitors").where({ monitor_tag: monitorTag }).del();
|
||||
|
||||
// Delete any configs that now have zero monitors
|
||||
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.deleteMonitorAlertConfig(id);
|
||||
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 +222,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 +330,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 +389,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 +411,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 +436,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 +479,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 +539,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);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user