mirror of
https://github.com/rajnandan1/kener.git
synced 2026-08-07 15:27:13 +00:00
Compare commits
247 Commits
chore/docs-4
...
fix/721
| Author | SHA1 | Date | |
|---|---|---|---|
| 74b6311e81 | |||
| c4c16d65a6 | |||
| 758cf5e4d5 | |||
| 7c17db12dc | |||
| d9954ea085 | |||
| 305c0a05fe | |||
| f83447f806 | |||
| 42ca67b172 | |||
| 806df0d73c | |||
| 5f66cada43 | |||
| 85788c76f7 | |||
| 604210568b | |||
| 41f5296227 | |||
| 8c1a97d844 | |||
| ed1a70d75b | |||
| e63a2f6311 | |||
| 951ab06f7e | |||
| af4684a90f | |||
| da6eaee3ab | |||
| f264115ab8 | |||
| 15b78dab66 | |||
| 54277ece9a | |||
| 54f056ad34 | |||
| 8d2808c291 | |||
| bd638ccf24 | |||
| c2945485e2 | |||
| a57c92fc0e | |||
| 8e7bc47b14 | |||
| cd26c46493 | |||
| 508b08f8f3 | |||
| 638393efac | |||
| 5a54d69d87 | |||
| 7e5ea5fda1 | |||
| 175cf605c6 | |||
| 6a9bfffbd4 | |||
| 7b120911b4 | |||
| 35817bc20a | |||
| a8fbac1b69 | |||
| cfc99e2f14 | |||
| 31ba10f434 | |||
| 1750e2a341 | |||
| a12df92b94 | |||
| 5d86084138 | |||
| 7050f780a3 | |||
| cb93089dcc | |||
| fcd05e1d68 | |||
| db9d7807e0 | |||
| b7e0756c54 | |||
| b920d2f9bc | |||
| 560c87219b | |||
| 94e24eec04 | |||
| 15680a58aa | |||
| 59f0eaef27 | |||
| 8362a73058 | |||
| 52f8c50f50 | |||
| 60868d55ca | |||
| f7e657ee95 | |||
| 63e5ec2886 | |||
| 2aef97c1ed | |||
| 51b2da97e0 | |||
| 50bddcd9a3 | |||
| bd36533b05 | |||
| 17500a0b43 | |||
| 0050cd810b | |||
| 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 | |||
| 7abd6788de | |||
| 681b116e09 | |||
| 04b1d0716b | |||
| 668f12a0eb | |||
| 394149a3d6 | |||
| 57c8ff28ab | |||
| 5b29af9e74 | |||
| 9f5b90cb93 | |||
| e4f001acf7 | |||
| 604dbccaea | |||
| 6b7d0335b7 | |||
| b55dd29f02 | |||
| dcd7918a08 | |||
| 38e3a538ef | |||
| 19c2275a7b | |||
| a53251454e | |||
| 8f39a0f649 | |||
| 674c310b67 | |||
| a550afca60 | |||
| e64d955ecb | |||
| 57fda67ed7 | |||
| 6d5a42aeb2 | |||
| 5d9160c36d | |||
| b465f48c6e | |||
| 8c16d99f55 | |||
| fdedbb78cf | |||
| 416beca1a6 | |||
| e261d3620e | |||
| 1089133b97 | |||
| 90de4ae4cb | |||
| 4e263284c8 | |||
| 343368631f | |||
| f5f40e1438 | |||
| 74f6601f5d | |||
| c4e03c1a83 | |||
| bb9124db0d | |||
| ce52506c95 | |||
| f20b74cb36 | |||
| f3a5839aae | |||
| 0896aed91e | |||
| d8ae54da53 | |||
| 66bf012c87 | |||
| 08fc54d2ea | |||
| b36e68f7c8 | |||
| 045f32b7ce | |||
| 7ae869d98a | |||
| efb04a4238 | |||
| e6e586f6de | |||
| e13d7fb1f4 | |||
| 23b0bae018 | |||
| e581346f84 | |||
| 555fd3a8a2 | |||
| 7a29d2f2ca | |||
| 7a3fe20083 | |||
| c16c119d65 | |||
| 048a12899d | |||
| 5fd0e691b3 | |||
| 28932f8df5 | |||
| 4da29ac4e1 | |||
| 396fc5e3c3 | |||
| af8daf98f6 | |||
| 2e91f90057 | |||
| 3213ab8efa | |||
| f2308d9bd1 | |||
| 623465ff50 | |||
| 7c224b3886 | |||
| 166073fbd4 | |||
| 373cbd5917 | |||
| 2c27859197 | |||
| bb5d58b675 | |||
| caca29d354 | |||
| 35f0adb235 | |||
| 8bcff45d87 | |||
| 3ad256a626 | |||
| c4b9181a0a | |||
| 1ba40fadf6 | |||
| 20f1481a01 | |||
| 828747876b | |||
| cbe0ea683f | |||
| 561864c625 | |||
| 3abdb5c17a | |||
| f2ef19e3d8 | |||
| 78b18a59ec | |||
| c28581b51d | |||
| f9486927de | |||
| 86645d9ea3 | |||
| feea1d76cd | |||
| 2e95f31d93 | |||
| 18cf8f51b7 | |||
| 9af842ebc0 | |||
| 3d157f1c20 |
@@ -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
|
||||
}
|
||||
}
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/ss-shadcn-svelte
|
||||
@@ -1,8 +1,9 @@
|
||||
---
|
||||
name: tailwindcss
|
||||
displayName: Tailwind CSS
|
||||
description: Tailwind CSS v4 utility-first styling patterns including responsive design, dark mode, and custom configuration. Use when styling with Tailwind, adding utility classes, configuring Tailwind, setting up dark mode, or customizing the theme.
|
||||
version: 1.0.0
|
||||
user-invokable: false
|
||||
metadata:
|
||||
category: styling
|
||||
---
|
||||
|
||||
# Tailwind CSS v4 Development Guidelines
|
||||
@@ -358,11 +359,3 @@ Tailwind v4 delivers 3.5x faster full builds (~100ms) compared to v3 using moder
|
||||
6. **Enable Dark Mode**: Plan for dark mode from the start
|
||||
7. **Use Plugins**: Leverage official plugins for common needs
|
||||
8. **Optimize Production**: Ensure purge is configured correctly
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For detailed information, see:
|
||||
|
||||
- [Utility Patterns](resources/utility-patterns.md)
|
||||
- [Component Library](resources/component-library.md)
|
||||
- [Configuration Guide](resources/configuration.md)
|
||||
|
||||
@@ -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,11 +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.
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Create Release (Deterministic)
|
||||
name: Create Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
- name: Bump package version
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
CURRENT_VERSION="$(node -p \"require('./package.json').version\")"
|
||||
CURRENT_VERSION=$(node -p 'require("./package.json").version')
|
||||
|
||||
if [ "$CURRENT_VERSION" != "$VERSION" ]; then
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
@@ -95,3 +95,4 @@ jobs:
|
||||
generate_release_notes: true
|
||||
make_latest: ${{ inputs.make_latest && 'true' || 'false' }}
|
||||
prerelease: ${{ inputs.prerelease }}
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
run: |
|
||||
TAG="${{ github.event.release.tag_name || github.ref_name }}"
|
||||
EXPECTED_VERSION="${TAG#v}"
|
||||
PACKAGE_VERSION="$(node -p \"require('./package.json').version\")"
|
||||
PACKAGE_VERSION=$(node -p 'require("./package.json").version')
|
||||
|
||||
if [ "$PACKAGE_VERSION" != "$EXPECTED_VERSION" ]; then
|
||||
echo "package.json version mismatch"
|
||||
@@ -83,6 +83,8 @@ jobs:
|
||||
BASE_SUFFIX=""
|
||||
fi
|
||||
|
||||
WITH_DOCS="false"
|
||||
|
||||
if [ "${{ matrix.variant }}" = "alpine" ]; then
|
||||
VARIANT_SUFFIX="-alpine"
|
||||
else
|
||||
@@ -94,6 +96,7 @@ jobs:
|
||||
echo "release_tag=${TAG}${FULL_SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_norm_tag=${NORM_TAG}${FULL_SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
echo "latest_tag=latest${FULL_SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
echo "with_docs=${WITH_DOCS}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "${{ matrix.variant }}" = "debian" ]; then
|
||||
echo "release_tag_debian_alias=${TAG}${BASE_SUFFIX}-debian" >> "$GITHUB_OUTPUT"
|
||||
@@ -129,7 +132,7 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VARIANT=${{ matrix.variant }}
|
||||
WITH_DOCS=true
|
||||
WITH_DOCS=${{ steps.vars.outputs.with_docs }}
|
||||
KENER_BASE_PATH=${{ matrix.base_path }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
|
||||
+2
-1
@@ -19,4 +19,5 @@ config/static/*
|
||||
!config/static/.kener
|
||||
**/*.yaml
|
||||
**/*.yml
|
||||
.github/
|
||||
.github/
|
||||
src/lib/components/ui
|
||||
@@ -22,6 +22,15 @@ You MUST use this tool whenever writing Svelte code before sending it to the use
|
||||
Generates a Svelte Playground link with the provided code.
|
||||
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
|
||||
|
||||
## Database compatibility rule
|
||||
|
||||
All database operations — migrations, queries, and repository functions — **MUST** work across all three supported databases: **SQLite**, **PostgreSQL**, and **MySQL**. Use Knex.js schema builder and query builder abstractions; avoid raw SQL unless wrapped in dialect-safe helpers or guarded with `try/catch`. When writing migrations:
|
||||
|
||||
- Use `knex.schema.hasColumn` / `knex.schema.hasTable` guards for idempotency.
|
||||
- Use Knex column types (`.string()`, `.integer()`, `.text()`, etc.) — never raw `ALTER TABLE` unless necessary.
|
||||
- For data-seeding inside migrations, use standard Knex query builder (`.insert()`, `.update()`, `.orderBy()`, `.first()`).
|
||||
- Test that `defaultTo()` values and `notNullable()` constraints work on all three engines.
|
||||
|
||||
## Documentation writing skill
|
||||
|
||||
When the user asks to write or edit documentation, follow the skill file:
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What is Kener?
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
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 (migrations run automatically first)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Dual Process Model
|
||||
|
||||
In development, `npm run dev` runs two parallel processes:
|
||||
|
||||
1. **SvelteKit dev server** (`vite dev`) - serves the frontend
|
||||
2. **Cron scheduler** (`vite-node src/lib/server/startup.ts`) - runs monitor checks, maintenance scheduler, daily cleanup
|
||||
|
||||
In production, `scripts/main.ts` is the single entry point: Express server + SvelteKit handler + migrations + seeds + scheduler startup.
|
||||
|
||||
### SvelteKit Route Groups
|
||||
|
||||
- **`(kener)/`** - Public status page
|
||||
- **`(manage)/`** - Admin dashboard (authenticated)
|
||||
- **`(embed)/`** - Embeddable widgets
|
||||
- **`(docs)/`** - Documentation pages
|
||||
- **`(api)/`** - SvelteKit API routes; also `src/lib/server/api-server/` for Express-side API handlers (file-based routing: `./action/method.ts`)
|
||||
- **`(account)/`** - Account/auth pages
|
||||
- **`(ext)/`** - External integrations
|
||||
- **`(assets)/`** - Asset serving
|
||||
|
||||
### Database
|
||||
|
||||
- **Knex.js** for query building and migrations. Supports SQLite (default), PostgreSQL, MySQL
|
||||
- Connection configured via `DATABASE_URL` env var: `sqlite://./path`, `postgresql://...`, `mysql://...`
|
||||
- Migrations in `/migrations/`, seeds in `/seeds/`
|
||||
- Always use the db singleton: `import db from "$lib/server/db/db"`
|
||||
|
||||
### Monitor Services
|
||||
|
||||
Each monitor type has a dedicated implementation in `src/lib/server/services/`:
|
||||
|
||||
- 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/`)
|
||||
|
||||
### 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`
|
||||
|
||||
## Key Conventions
|
||||
|
||||
### Svelte 5 + TypeScript
|
||||
|
||||
- Use **TypeScript** for new/modified code
|
||||
- Use **Svelte 5 runes** (`$state`, `$derived`, `$effect`, `$props()`) in new components
|
||||
- Use generated `$types` for SvelteKit route typing (`import type { PageServerLoad } from './$types'`)
|
||||
- Use `import type { ... }` for type imports
|
||||
|
||||
### UI Components
|
||||
|
||||
- **shadcn-svelte** components in `src/lib/components/ui/`
|
||||
- Import: `import { Button } from "$lib/components/ui/button"`
|
||||
- Styling: **Tailwind CSS v4** with HSL CSS variables for theming
|
||||
|
||||
### Timestamps
|
||||
|
||||
All timestamps are **UTC seconds** (not milliseconds). Use helpers from `src/lib/server/tool.ts`.
|
||||
|
||||
### Status Constants
|
||||
|
||||
Constants are exported as a **default export** from `src/lib/global-constants.ts`:
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
// 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/apiController"`
|
||||
|
||||
### Types Location
|
||||
|
||||
- `src/lib/types/` - Shared types (client + server)
|
||||
- `src/lib/server/types/` - Server-only types
|
||||
- `src/lib/client/types/` - Client-only types
|
||||
|
||||
### i18n
|
||||
|
||||
Locale files in `src/lib/locales/`. Add translations by creating `{code}.json` and updating `locales.json`.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
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
|
||||
|
||||
Read `.claude/skills/` for specialized instructions on:
|
||||
|
||||
- **svelte-code-writer** - Svelte component creation/editing
|
||||
- **documentation-writer** - Editing docs in `src/routes/(docs)/docs/content/`
|
||||
- **tailwindcss** - Tailwind CSS v4 patterns
|
||||
|
||||
## Agent skills
|
||||
|
||||
### Issue tracker
|
||||
|
||||
Issues and PRDs are tracked in GitHub Issues for `rajnandan1/kener`. See `docs/agents/issue-tracker.md`.
|
||||
|
||||
### Triage labels
|
||||
|
||||
Triage uses the default mattpocock/skills label vocabulary. See `docs/agents/triage-labels.md`.
|
||||
|
||||
### Domain docs
|
||||
|
||||
This repo uses a single-context domain-doc layout. See `docs/agents/domain.md`.
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
# Kener
|
||||
|
||||
An open-source status page application providing real-time monitoring, uptime tracking, incident management, and customizable dashboards.
|
||||
|
||||
## Language
|
||||
|
||||
### Monitoring
|
||||
|
||||
**Monitor**:
|
||||
A single check against a service, with a unique tag, a type (API, Ping, TCP, DNS, SSL, SQL, Heartbeat, GameDig, Group, gRPC, None), and a status (ACTIVE or otherwise).
|
||||
_Avoid_: Check, probe, service
|
||||
|
||||
**Inactive Monitor**:
|
||||
A monitor that is not checked at all: the scheduler drops its job and no monitoring data is collected until it is made ACTIVE again. Independent of visibility (see Hidden Monitor).
|
||||
_Avoid_: Disabled monitor, paused monitor
|
||||
|
||||
**Hidden Monitor**:
|
||||
A monitor excluded from all status pages while remaining fully checked and alerted. Independent of ACTIVE/INACTIVE.
|
||||
_Avoid_: Invisible monitor, private monitor
|
||||
|
||||
**Group Monitor**:
|
||||
A monitor whose status is derived from other monitors via a weighted score (UP=1, DEGRADED=0.5, DOWN=0; maintenance counts as UP). A group cannot contain another group.
|
||||
_Avoid_: Monitor group, composite monitor
|
||||
|
||||
**Member**:
|
||||
A monitor belonging to a Group Monitor, carrying a weight and a position in the execution order. Membership is an explicit stored list, never a dynamic rule (e.g. tag wildcards).
|
||||
_Avoid_: Child monitor, sub-monitor
|
||||
|
||||
**Weight**:
|
||||
A member's share of the group score, between 0 and 1. Weights across a group's members must sum to 1. Any membership change (add or remove) redistributes all weights equally; manual tuning happens after membership is settled.
|
||||
|
||||
**Execution Order**:
|
||||
The stored order in which a Group Monitor's members are checked before aggregation. Manually arranged, not derived.
|
||||
|
||||
**Eligible Monitor**:
|
||||
A monitor that may become a Member: ACTIVE, not a Group Monitor, and not the group being edited itself.
|
||||
|
||||
**Monitoring Sample**:
|
||||
One recorded data point for a monitor at a timestamp: a status, a latency, and a sample type describing how it was produced. Every sample is either Observed or Synthetic.
|
||||
_Avoid_: Data point, record, check result
|
||||
|
||||
**Observed Sample**:
|
||||
A Monitoring Sample produced by a check that actually ran against the target, whatever the outcome: a clean evaluation (`REALTIME`), a timed-out check (`TIMEOUT`), or a check that errored (`ERROR`). A check failing to reach the target is itself an observation — for most monitor types that is exactly what "down" looks like.
|
||||
|
||||
**Synthetic Sample**:
|
||||
A Monitoring Sample written by the system or an admin rather than by a check: a raw heartbeat receipt (`SIGNAL`), a status pushed through the data API (`MANUAL`), a default-status fill (`DEFAULT_STATUS`), a last-known-status fill (`CARRIED`), or an incident/maintenance overlay (`INCIDENT`, `MAINTENANCE`).
|
||||
|
||||
**Default Status**:
|
||||
A monitor's answer to what a minute without a Monitoring Sample means. Exactly one choice from a closed set: nothing (`NONE` — the minute shows no data), a fixed status (`UP`, `DOWN`, `DEGRADED`) written as default-status fill, or Last Known Status. `MAINTENANCE` is not a Default Status (a maintenance overlay is an event, not a fill).
|
||||
_Avoid_: Fallback status, fill status
|
||||
|
||||
**Last Known Status**:
|
||||
A Default Status choice where a minute without a sample repeats the most recent Alert-Visible Sample — status and latency alike — written as a Carried Sample (`CARRIED`). Carried Samples are themselves alert-visible, so the chain continues from the last live statement: overlays and heartbeat receipts never become sticky, backdated corrections do not change the present, and alerts trigger and resolve on carried minutes like any other. Carry never expires and never backfills: it starts at the tick after the choice is made, and Carried Samples persist as history if the choice is later changed. A monitor with no Alert-Visible Sample yet has nothing to carry — its minutes show no data. Only None-type monitors may choose it; changing the monitor's type away from None resets the Default Status to UP.
|
||||
_Avoid_: Sticky status, carry-forward mode
|
||||
|
||||
**Stale Member**:
|
||||
A Member whose monitor is no longer an Eligible Monitor (paused or deleted after being added). It remains a Member until explicitly removed, but is excluded from the group score.
|
||||
|
||||
### Alerting
|
||||
|
||||
**Alert Configuration**:
|
||||
A per-monitor alerting rule: a condition (status, latency, or uptime against a value), a Failure Threshold, a Success Threshold, whether triggering creates an incident, and the Triggers to notify.
|
||||
_Avoid_: Alert rule, alarm
|
||||
|
||||
**Alert**:
|
||||
A fired instance of an Alert Configuration for a monitor. TRIGGERED when the condition holds, RESOLVED when the resolve condition later holds. May own the incident it created.
|
||||
_Avoid_: Alarm, notification (that's what Triggers send)
|
||||
|
||||
**Trigger**:
|
||||
A notification channel (email, webhook, Discord, Slack) that Alert Configurations notify on trigger and on resolve.
|
||||
_Avoid_: Notifier, channel
|
||||
|
||||
**Alert-Visible Sample**:
|
||||
A Monitoring Sample that alert evaluation can see: every Observed Sample, plus data-API pushes (`MANUAL`), default-status fill (`DEFAULT_STATUS`), and last-known-status fill (`CARRIED`). Raw heartbeat receipts (`SIGNAL`) and incident/maintenance overlays are never alert-visible — while an overlay is active the alert window freezes (alerts neither trigger nor resolve). All alert conditions (status and latency alike) evaluate the same alert-visible timeline.
|
||||
|
||||
**Failure Threshold**:
|
||||
The number of consecutive Alert-Visible Samples matching the condition required to trigger an Alert.
|
||||
|
||||
**Success Threshold**:
|
||||
The number of consecutive Alert-Visible Samples meeting the resolve condition required to resolve an Alert.
|
||||
|
||||
### Pages
|
||||
|
||||
**Page**:
|
||||
A public status page with its own path, title, monitors, and display settings. Served at `/<page_path>`.
|
||||
|
||||
**Home Page**:
|
||||
The Page served at the site root. Its stored path is empty, it always exists (it can not be deleted), and its path can not be changed. Addressed in the API by the `~home` token.
|
||||
_Avoid_: Default page, base page, root page
|
||||
|
||||
**Status History Window**:
|
||||
The number of days of per-day status shown for a monitor, per device class (desktop/mobile). Configurable at two levels with the same defaults and bounds: per Page (applies to all its monitors) and per Monitor (overrides the page level).
|
||||
_Avoid_: History days, bar count
|
||||
|
||||
**Page Settings**:
|
||||
A Page's display configuration: status-history window per device class, monitor layout style, per-page meta/social overrides, and event display preferences. The admin UI and the API expose the same settings, though each surface may name fields differently; a writer must never drop fields it does not understand.
|
||||
_Avoid_: Display settings (ambiguous with site-wide event display settings)
|
||||
|
||||
### Maintenance
|
||||
|
||||
**Maintenance**:
|
||||
A recurring maintenance definition: title, description, an RRULE schedule, a duration, and affected monitors. Identified by its own id.
|
||||
_Avoid_: Maintenance window, maintenance event (that's an occurrence, see below)
|
||||
|
||||
**Maintenance Event**:
|
||||
A single occurrence of a Maintenance, generated from its RRULE: a concrete start/end time with a lifecycle status (SCHEDULED → READY → ONGOING → COMPLETED). Has its own id, independent of the Maintenance id. The public maintenance page is keyed by Maintenance Event id.
|
||||
_Avoid_: Occurrence, maintenance instance
|
||||
@@ -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,8 +163,12 @@ 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)
|
||||
COPY --chown=node:node --from=builder /app/src/lib/locales ./src/lib/locales
|
||||
|
||||
# Build output (SvelteKit client/server + esbuild main.js) — changes most often
|
||||
COPY --chown=node:node --from=builder /app/build ./build
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Raj Nandan Sharma
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,10 +1,5 @@
|
||||
# Kener - Stunning Status Pages
|
||||
|
||||
<details>
|
||||
<summary>Upcoming Version 4.0.0</summary>
|
||||
Currently we are working on updating kener to the latest svelte version with typescript
|
||||
</details>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://kener.ing/og.jpg?v=1" width="100%" height="auto" class="rounded-lg shadow-lg" alt="kener example illustration">
|
||||
</p>
|
||||
@@ -25,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">
|
||||
@@ -51,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.
|
||||
@@ -173,11 +177,7 @@ PORT=3000
|
||||
|
||||
For the full quick start (including local Docker builds and dev mode), see the docs:
|
||||
|
||||
- https://kener.ing/docs/quick-start
|
||||
|
||||
## One Click Deployment
|
||||
|
||||
[](https://railway.com/template/spSvic?referralCode=1Pn7vs)
|
||||
- https://kener.ing/docs/v4/getting-started/quick-start
|
||||
|
||||
## Features
|
||||
|
||||
@@ -207,10 +207,6 @@ Kener combines public status page essentials with advanced admin workflows.
|
||||
- Integrate analytics providers like GA, Plausible, Mixpanel, Umami, and Clarity
|
||||
- Access the full REST API for incidents, monitors, and reporting
|
||||
|
||||
<div align="left">
|
||||
<img alt="Visitor Stats" src="https://widgetbite.com/stats/rajnandan"/>
|
||||
</div>
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- [SvelteKit](https://kit.svelte.dev/)
|
||||
@@ -220,11 +216,9 @@ Kener combines public status page essentials with advanced admin workflows.
|
||||
|
||||
If you’re enjoying Kener and want to support its development, consider sponsoring me on GitHub or treating me to a coffee. Your support helps keep the project growing! 🚀
|
||||
|
||||
[Sponsor Me Using Github](https://github.com/sponsors/rajnandan1)
|
||||
- [Sponsor Me Using GitHub](https://github.com/sponsors/rajnandan1)
|
||||
|
||||
☕ [Buy Me a Coffee](https://www.buymeacoffee.com/rajnandan1)
|
||||
|
||||

|
||||
- [Buy Me a Coffee](https://www.buymeacoffee.com/rajnandan1)
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -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,5 @@
|
||||
# Group membership is an explicit stored list, not a rule
|
||||
|
||||
When making group-monitor member selection searchable (#694), the requester also proposed dynamic membership by tag pattern (e.g. `site1-*` auto-adds matching monitors). We decided group membership stays an explicit, stored list of members. Dynamic membership contradicts the group model: each member carries an explicit weight (weights must sum to 1) and a manual execution order — a rule that adds/removes members over time would need an auto-weighting policy, silent weight redistribution when monitors are created or deleted, and an undefined execution order for matched members. Bulk needs are served in the editor instead: search plus "Add all N matching" makes large explicit groups cheap to build.
|
||||
|
||||
If wildcard groups are requested again, the answer is here: it's a different feature (a rule-based aggregate without weights or order), not an extension of Group Monitors.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Public maintenance page is keyed by Maintenance Event id
|
||||
|
||||
The public route `/maintenances/<id>` interprets `<id>` as a Maintenance Event id by default (with `?type=maintenance` to address a Maintenance definition instead), even though the route param is named `maintenance_id`. Issue #723 showed this misleads API consumers: `/api/v4/maintenances` returns Maintenance ids, and linking those to `/maintenances/<id>` lands on whatever Event happens to carry that id — an apparent "off-by-one title mismatch" with no actual data corruption.
|
||||
|
||||
We considered flipping the route default to Maintenance ids but rejected it: every internal status-page link, subscriber email, and externally bookmarked URL is keyed by Event id, and all of those would break. Instead, v4 API responses carry an absolute `url` field (built from the configured Site URL) that resolves correctly — `?type=maintenance` for Maintenance objects, the plain Event-id path for Maintenance Events. Consumers should link via `url`, never by concatenating ids onto paths.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Fail-fast, self-healing database pool defaults
|
||||
|
||||
`knexfile.ts` overrides knex's pool defaults for network databases (Postgres, MySQL): `pool.min` is 0 instead of 2, acquire/create timeouts are 15s instead of 60s/30s, and TCP keepalive is enabled on connections. All knobs are overridable via `DATABASE_*` env vars.
|
||||
|
||||
Two production incidents drove this. On Railway, a Postgres outage caused every request to hang for knex's default 60s `acquireConnectionTimeout` before failing with `KnexTimeoutError`, and after the database recovered the app stayed broken until a manual restart. In Docker Swarm (#692), the overlay network's conntrack silently dropped idle TCP connections after ~20 minutes, so the first request after an idle period drew a dead socket from the pool and returned a 500; the reporter worked around it with server-side Postgres `tcp_keepalives_*` settings and asked for an application-level fix.
|
||||
|
||||
Both share one root cause: knex keeps `pool.min` connections forever and never validates them. Those permanently-idle sockets are exactly the ones cloud networks (Railway proxies, Swarm overlays, k8s) silently kill, and after any database blip they wedge the pool with corpses. `min: 0` lets the reaper retire every idle connection (`idleTimeoutMillis` 30s, well under typical conntrack windows), keepalive lets the OS detect silently-dropped sockets, and the 15s timeouts turn a minute-long hang into a fast failure during an outage.
|
||||
|
||||
The trade-off: a quiet instance pays connection setup on the first query after idle (tens of milliseconds), and a database that takes longer than 15s to accept connections will see failures where the old defaults would have waited a minute. Deployments with such databases can raise `DATABASE_ACQUIRE_TIMEOUT_MS` / `DATABASE_CREATE_TIMEOUT_MS` rather than the project reverting to defaults that wedge everyone else.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Home page is addressed as `~home` in the v4 API
|
||||
|
||||
The Home Page is stored with an empty `page_path`, which can not appear as a URL segment, so `/api/v4/pages/{page_path}` could not address it at all (#737). The v4 API now accepts the special segment `~home` for it: the request middleware maps `~home` to a lookup of the empty path before handlers run.
|
||||
|
||||
We considered the reporter's suggestion of a `default` keyword, and `home`, but both are valid page paths under the sanitizer (`[a-z0-9_-]`), so a real page could shadow the keyword or force reservation rules and migration edge cases. We also considered addressing pages by id, which either breaks the existing path-based contract or is ambiguous with numeric page paths. `~home` can never collide because the sanitizer strips `~`, and tilde is an RFC 3986 unreserved character, so clients never need to encode it (percent-encoded `%7Ehome` works too, since the middleware decodes segments).
|
||||
|
||||
Semantics follow the server-side invariants the manage UI relies on: `PATCH` via `~home` accepts every field except `page_path`, which is fixed for the home page (the UI disables the field), and `DELETE` is rejected — `DeletePage` in `pagesController` throws "Cannot delete the home page" and the rest of the app assumes the home page exists.
|
||||
|
||||
API responses also render the home page's `page_path` as `~home` (list, single, and write responses), so what a consumer reads is exactly what it can address — list → pick → `PATCH` round-trips cleanly, including read-modify-write bodies that send `~home` back (treated as "no path change"). The stored path remains empty and the public URL remains the site root; consumers must not build public URLs by concatenating `page_path`.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Alerts evaluate alert-visible samples, not just REALTIME ones
|
||||
|
||||
The consecutive-sample checks behind alert evaluation (`consecutivelyStatusFor`, `consecutivelyLatencyGreaterThan`, `consecutivelyLatencyLessThan` in `src/lib/server/db/repositories/monitoring.ts`) consider samples whose type is `REALTIME`, `ERROR`, `TIMEOUT`, `MANUAL`, or `DEFAULT_STATUS` — the "alert-visible" set — instead of `REALTIME` only. Both data-API PATCH endpoints (single timestamp and range) enqueue one alert evaluation after writing `MANUAL` rows. `SIGNAL` rows and `INCIDENT`/`MAINTENANCE` overlay rows remain invisible to alerting. Amended by ADR 0006: last-known-status fill (`CARRIED`) later joined the alert-visible set under the same invariant.
|
||||
|
||||
Two issues drove this. In #633, a GameDig monitor showed DOWN on the status page but never alerted: a down game server makes `GameDig.query` throw, so every down-sample is recorded as `ERROR`, which the old `type = REALTIME` filter excluded — the "N consecutive DOWN" condition could never become true. The same failure mode silently broke gRPC, SQL, and SSL monitors (hard-down records `ERROR`) and API monitors whose outage manifests as timeouts (`TIMEOUT`). In #720, a NONE monitor driven by the data API never alerted for two stacked reasons: PATCH writes `MANUAL` rows the filter excluded, and the endpoint never enqueued evaluation at all. The status page and UPTIME alerts have no type filter, which is why users saw DOWN while alerts stayed silent.
|
||||
|
||||
The whitelist is exactly the set of types written by flows that trigger alert evaluation — scheduler checks (`REALTIME`/`ERROR`/`TIMEOUT`), default-status fill (`DEFAULT_STATUS`), and data-API pushes (`MANUAL`). That invariant ("every enqueuer of evaluation contributes a row the evaluator can see") is what keeps the un-time-bounded last-N query self-healing: without `DEFAULT_STATUS` in the set, a NONE monitor with a default status evaluates every minute against rows it cannot see, so stale backfilled `MANUAL` rows would rank as "the last N" and fire alerts weeks after the fact. The rejected alternatives: fixing only `gamedigCall` to emit `REALTIME` on query failure (fixes one monitor type out of five, loses the stored down-vs-errored diagnostic, and does nothing for existing data), and time-bounding the query (the cutoff must scale with each monitor's cron, which means parsing cron expressions in the alert path for marginal benefit).
|
||||
|
||||
The trade-offs, accepted deliberately for one uniform rule across status and latency alerts: a service that degrades from slow to hard-down resolves an active latency alert (error samples carry latency 0, satisfying "consecutively below threshold") — the status alert is the one that covers outages; a NONE monitor with a default status auto-resolves MANUAL-pushed alerts once default fill resumes, because a default status is an explicit statement that absence of pushes means that status; and the fix is retroactive, so monitors that were already down at upgrade time alert shortly after — which is the bug report, inverted.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Last Known Status is a Default Status choice that repeats the latest alert-visible sample
|
||||
|
||||
Issue #721: push-driven NONE monitors lost their status between pushes in v4 — one red minute, then gray forever — because the per-minute sample model only fills gaps with a static `default_status`. We added a fifth Default Status choice, `LAST_KNOWN`, where each tick with no observed sample writes a `CARRIED` row repeating the most recent alert-visible sample — status and latency alike. It is modeled as a dropdown value rather than a separate "sticky" checkbox so that "what does a minute without a sample mean" stays a single dimension with no conflicting combinations; the same cleanup removed the `MAINTENANCE` option, which the UI offered but the fill engine had always silently ignored (stored `MAINTENANCE`/unknown values migrate to `NONE`, preserving behavior).
|
||||
|
||||
The carry source is the most recent **Alert-Visible Sample** by timestamp, and `CARRIED` itself joins the alert-visible set. That one rule does three jobs: incident/maintenance overlays and raw heartbeat `SIGNAL` receipts can never become sticky; backdated data-API corrections cannot rewrite the present (newer carried rows outrank them); and ADR 0005's invariant — every flow that enqueues alert evaluation contributes a row the evaluator can see — keeps holding. The consequences were accepted deliberately: a single DOWN push triggers status alerts once carried minutes meet the failure threshold, alerts never auto-resolve (recovery must be pushed — unlike a fixed default, nothing "resumes"), and enabling the option on a monitor whose last push was DOWN fires the alert shortly after — the bug report, inverted, same as ADR 0005.
|
||||
|
||||
Rejected alternatives: a staleness cap ("carry for at most X, then gray") re-introduces "absence means unknown" — the opposite of what the admin just selected — and dead-integration detection is what Heartbeat monitors are for; reusing the `DEFAULT` sample type for carried rows loses the stored distinction between "admin declared absence means X" and "system repeated the last live statement"; backfilling the gap on enable would mutate historical uptime, so carry is tick-forward only and `CARRIED` rows persist as history if the setting is later changed. `LAST_KNOWN` is selectable only on NONE-type monitors — for polled types an observed sample wins the merge every tick, so offering it would be a dormant knob; changing a monitor's type away from NONE auto-resets the Default Status to UP so the invalid combination never persists.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,794 @@
|
||||
# Last Known Status (fix #721) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a `LAST_KNOWN` Default Status choice for NONE-type (Manual) monitors: each scheduler tick without new data writes a `CARRIED` sample repeating the most recent alert-visible sample (status + latency), so push-driven monitors keep their status between pushes.
|
||||
|
||||
**Architecture:** The carry fill slots into the existing `defaultData` branch of the monitor-execute worker (`monitorExecuteQueue.ts`), sourcing from a new repository query for the latest alert-visible sample. A single normalization helper in `monitorsController.ts` enforces the closed `default_status` value set (`NONE|UP|DOWN|DEGRADED|LAST_KNOWN`) and the "LAST_KNOWN only on NONE-type, auto-reset to UP otherwise" rule across all three monitor write paths (manage UI action, v4 POST, v4 PATCH). A migration normalizes legacy values (`MAINTENANCE`/unknown/NULL → `NONE`).
|
||||
|
||||
**Tech Stack:** SvelteKit 2 (Svelte 5 runes), Knex migrations, BullMQ workers, shadcn-svelte UI.
|
||||
|
||||
**Design authority:** `docs/adr/0006-last-known-status-fill.md` and the `Default Status` / `Last Known Status` / `Alert-Visible Sample` entries in `CONTEXT.md`. If a step seems to contradict those, the docs win.
|
||||
|
||||
**Verification approach:** This repo has NO test infrastructure (no test script, no tests/ dir). Backend logic is verified with throwaway `vite-node` scripts driving repository classes against in-memory better-sqlite3 (delete the scripts before committing), plus a live end-to-end pass against the dev server. `npm run check` gates every commit.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Constants + alert-visible whitelist
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/lib/global-constants.ts:43`
|
||||
- Modify: `src/lib/server/db/repositories/monitoring.ts:13-20`
|
||||
|
||||
- [ ] **Step 1: Add the two constants**
|
||||
|
||||
In `src/lib/global-constants.ts`, the default export object currently has (line 43):
|
||||
|
||||
```typescript
|
||||
DEFAULT_STATUS: "DEFAULT",
|
||||
```
|
||||
|
||||
Add two lines directly after it:
|
||||
|
||||
```typescript
|
||||
DEFAULT_STATUS: "DEFAULT",
|
||||
CARRIED: "CARRIED",
|
||||
LAST_KNOWN: "LAST_KNOWN",
|
||||
```
|
||||
|
||||
(`CARRIED` is a **sample type** written by last-known-status fill; `LAST_KNOWN` is a **default_status value** stored on the monitor. They are different namespaces that happen to live in the same constants object — keep both names exactly as above.)
|
||||
|
||||
- [ ] **Step 2: Add CARRIED to the alert-visible whitelist**
|
||||
|
||||
In `src/lib/server/db/repositories/monitoring.ts`, replace lines 13-20:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Sample types alert evaluation can see (see docs/adr/0005-alerts-evaluate-alert-visible-samples.md).
|
||||
* Exactly the types written by flows that enqueue alert evaluation: scheduler checks
|
||||
* (REALTIME/ERROR/TIMEOUT), default-status fill (DEFAULT_STATUS), and data-API pushes (MANUAL).
|
||||
* SIGNAL rows (raw heartbeat receipts) and INCIDENT/MAINTENANCE overlays stay invisible, so the
|
||||
* alert window freezes during manual overlays instead of triggering or resolving on them.
|
||||
*/
|
||||
const ALERT_VISIBLE_TYPES = [GC.REALTIME, GC.ERROR, GC.TIMEOUT, GC.MANUAL, GC.DEFAULT_STATUS]
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Sample types alert evaluation can see (see docs/adr/0005-alerts-evaluate-alert-visible-samples.md
|
||||
* and docs/adr/0006-last-known-status-fill.md).
|
||||
* Exactly the types written by flows that enqueue alert evaluation: scheduler checks
|
||||
* (REALTIME/ERROR/TIMEOUT), default-status fill (DEFAULT_STATUS), last-known-status fill (CARRIED),
|
||||
* and data-API pushes (MANUAL).
|
||||
* SIGNAL rows (raw heartbeat receipts) and INCIDENT/MAINTENANCE overlays stay invisible, so the
|
||||
* alert window freezes during manual overlays instead of triggering or resolving on them.
|
||||
*/
|
||||
const ALERT_VISIBLE_TYPES = [GC.REALTIME, GC.ERROR, GC.TIMEOUT, GC.MANUAL, GC.DEFAULT_STATUS, GC.CARRIED]
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Type-check**
|
||||
|
||||
Run: `npm run check`
|
||||
Expected: 0 errors (same error/warning count as before the change — run it on a clean tree first if unsure).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/global-constants.ts src/lib/server/db/repositories/monitoring.ts
|
||||
git commit -m "feat(constants): add CARRIED sample type and LAST_KNOWN default status"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Repository — latest alert-visible sample query
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/lib/server/db/repositories/monitoring.ts` (after `getLatestMonitoringData`, line 79)
|
||||
- Modify: `src/lib/server/db/dbimpl.ts:52-53` (declaration) and `:412` (binding)
|
||||
|
||||
- [ ] **Step 1: Add the repository method**
|
||||
|
||||
In `src/lib/server/db/repositories/monitoring.ts`, directly after the `getLatestMonitoringData` method (ends line 79), add:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Latest sample the alert evaluator (and last-known-status fill) can see.
|
||||
* Carry source for Default Status = LAST_KNOWN (docs/adr/0006): overlays
|
||||
* (INCIDENT/MAINTENANCE) and raw heartbeat receipts (SIGNAL) are excluded,
|
||||
* so they can never become sticky.
|
||||
*/
|
||||
async getLatestAlertVisibleData(monitor_tag: string): Promise<MonitoringData | undefined> {
|
||||
return await this.knex("monitoring_data")
|
||||
.where("monitor_tag", monitor_tag)
|
||||
.whereIn("type", ALERT_VISIBLE_TYPES)
|
||||
.orderBy("timestamp", "desc")
|
||||
.limit(1)
|
||||
.first();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Expose it on the db singleton**
|
||||
|
||||
In `src/lib/server/db/dbimpl.ts`, after line 52 (`getLatestMonitoringData!: ...`), add the declaration:
|
||||
|
||||
```typescript
|
||||
getLatestAlertVisibleData!: MonitoringRepository["getLatestAlertVisibleData"];
|
||||
```
|
||||
|
||||
and after line 412 (`this.getLatestMonitoringData = ...bind(this.monitoring);`), add the binding:
|
||||
|
||||
```typescript
|
||||
this.getLatestAlertVisibleData = this.monitoring.getLatestAlertVisibleData.bind(this.monitoring)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write the throwaway verification script**
|
||||
|
||||
Create `scripts/tmp-verify-carry-source.ts` (will be deleted, never committed):
|
||||
|
||||
```typescript
|
||||
import Knex from "knex"
|
||||
import { MonitoringRepository } from "../src/lib/server/db/repositories/monitoring"
|
||||
|
||||
const knex = Knex({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true })
|
||||
|
||||
await knex.schema.createTable("monitoring_data", (t) => {
|
||||
t.string("monitor_tag")
|
||||
t.integer("timestamp")
|
||||
t.string("status")
|
||||
t.float("latency")
|
||||
t.string("type")
|
||||
t.text("error_message")
|
||||
t.primary(["monitor_tag", "timestamp"])
|
||||
})
|
||||
|
||||
const repo = new MonitoringRepository(knex)
|
||||
|
||||
// Timeline: MANUAL DOWN, then a CARRIED copy, then an INCIDENT overlay, then a SIGNAL receipt.
|
||||
await knex("monitoring_data").insert([
|
||||
{ monitor_tag: "t", timestamp: 100, status: "DOWN", latency: 42, type: "MANUAL" },
|
||||
{ monitor_tag: "t", timestamp: 160, status: "DOWN", latency: 42, type: "CARRIED" },
|
||||
{ monitor_tag: "t", timestamp: 220, status: "UP", latency: 0, type: "INCIDENT" },
|
||||
{ monitor_tag: "t", timestamp: 280, status: "UP", latency: 0, type: "SIGNAL" }
|
||||
])
|
||||
|
||||
const latest = await repo.getLatestAlertVisibleData("t")
|
||||
console.log("latest:", latest)
|
||||
if (!latest || latest.timestamp !== 160 || latest.type !== "CARRIED" || latest.status !== "DOWN") {
|
||||
throw new Error("FAIL: expected the CARRIED row at ts=160 (INCIDENT/SIGNAL must be skipped)")
|
||||
}
|
||||
|
||||
const none = await repo.getLatestAlertVisibleData("missing")
|
||||
if (none !== undefined) throw new Error("FAIL: expected undefined for unknown tag")
|
||||
|
||||
console.log("PASS")
|
||||
await knex.destroy()
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run it**
|
||||
|
||||
Run: `npx vite-node scripts/tmp-verify-carry-source.ts`
|
||||
Expected: prints the ts=160 CARRIED row, then `PASS`.
|
||||
|
||||
- [ ] **Step 5: Delete the script, type-check, commit**
|
||||
|
||||
```bash
|
||||
rm scripts/tmp-verify-carry-source.ts
|
||||
npm run check
|
||||
git add src/lib/server/db/repositories/monitoring.ts src/lib/server/db/dbimpl.ts
|
||||
git commit -m "feat(db): add getLatestAlertVisibleData query for last-known-status carry source"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Engine — carry fill in the execute worker
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/lib/server/queues/monitorExecuteQueue.ts:125-156`
|
||||
|
||||
- [ ] **Step 1: Extend the defaultData branch**
|
||||
|
||||
In `src/lib/server/queues/monitorExecuteQueue.ts`, replace lines 125-139:
|
||||
|
||||
```typescript
|
||||
let defaultData: MonitoringResultTS = {}
|
||||
let mergedData: MonitoringResultTS = {}
|
||||
|
||||
if (monitor.default_status !== undefined && monitor.default_status !== null) {
|
||||
if (([GC.UP, GC.DOWN, GC.DEGRADED] as string[]).indexOf(monitor.default_status) !== -1) {
|
||||
defaultData[ts] = {
|
||||
status: monitor.default_status,
|
||||
latency: 0,
|
||||
type: GC.DEFAULT_STATUS
|
||||
}
|
||||
if (monitor.default_status !== GC.UP) {
|
||||
defaultData[ts].error_message = "Default status applied"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
let defaultData: MonitoringResultTS = {}
|
||||
let mergedData: MonitoringResultTS = {}
|
||||
|
||||
if (monitor.default_status !== undefined && monitor.default_status !== null) {
|
||||
if (([GC.UP, GC.DOWN, GC.DEGRADED] as string[]).indexOf(monitor.default_status) !== -1) {
|
||||
defaultData[ts] = {
|
||||
status: monitor.default_status,
|
||||
latency: 0,
|
||||
type: GC.DEFAULT_STATUS
|
||||
}
|
||||
if (monitor.default_status !== GC.UP) {
|
||||
defaultData[ts].error_message = "Default status applied"
|
||||
}
|
||||
} else if (monitor.default_status === GC.LAST_KNOWN) {
|
||||
// Last Known Status fill (docs/adr/0006): repeat the most recent alert-visible
|
||||
// sample — status and latency alike. No sample yet → nothing to carry → no fill.
|
||||
const lastKnown = await db.getLatestAlertVisibleData(monitor.tag)
|
||||
if (lastKnown && lastKnown.status) {
|
||||
defaultData[ts] = {
|
||||
status: lastKnown.status,
|
||||
latency: lastKnown.latency ?? 0,
|
||||
type: GC.CARRIED
|
||||
}
|
||||
if (lastKnown.status !== GC.UP) {
|
||||
defaultData[ts].error_message = "Last known status applied"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Fix the NO_DATA-preference block to preserve the fill's type**
|
||||
|
||||
Still in the same file, the block at (previously) lines 141-156 hardcodes `type: GC.DEFAULT_STATUS` when realtime returns NO_DATA but a fill exists. A heartbeat monitor with `LAST_KNOWN` would mislabel its carried rows. Replace:
|
||||
|
||||
```typescript
|
||||
const defaultStatus = defaultData[ts]?.status
|
||||
const realtimeStatus = realtimeData[ts]?.status
|
||||
let realtimeDataForMerge = realtimeData
|
||||
if (defaultStatus && realtimeStatus === GC.NO_DATA) {
|
||||
// Apply the preference *before* merging so incident/maintenance can still override later.
|
||||
// Also avoid carrying over realtime NO_DATA error_message.
|
||||
realtimeDataForMerge = { ...realtimeData }
|
||||
realtimeDataForMerge[ts] = {
|
||||
...realtimeDataForMerge[ts],
|
||||
status: defaultStatus,
|
||||
type: GC.DEFAULT_STATUS
|
||||
}
|
||||
delete realtimeDataForMerge[ts].error_message
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
const defaultStatus = defaultData[ts]?.status
|
||||
const realtimeStatus = realtimeData[ts]?.status
|
||||
let realtimeDataForMerge = realtimeData
|
||||
if (defaultStatus && realtimeStatus === GC.NO_DATA) {
|
||||
// Apply the preference *before* merging so incident/maintenance can still override later.
|
||||
// Also avoid carrying over realtime NO_DATA error_message.
|
||||
// Keep the fill's own type: DEFAULT for fixed fill, CARRIED for last-known fill.
|
||||
realtimeDataForMerge = { ...realtimeData }
|
||||
realtimeDataForMerge[ts] = {
|
||||
...realtimeDataForMerge[ts],
|
||||
status: defaultStatus,
|
||||
type: defaultData[ts].type
|
||||
}
|
||||
delete realtimeDataForMerge[ts].error_message
|
||||
}
|
||||
```
|
||||
|
||||
Note: `latency` in this branch intentionally stays whatever realtime reported — unchanged from today for fixed fill; for LAST_KNOWN-on-heartbeat the carried latency was already placed in `defaultData[ts]` and `mergedData` spread order (`{ ...defaultData, ...realtimeDataForMerge, ... }`) means the realtime object wins the spread; this matches existing fixed-fill behavior, do not "improve" it here.
|
||||
|
||||
- [ ] **Step 3: Type-check**
|
||||
|
||||
Run: `npm run check`
|
||||
Expected: 0 new errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/server/queues/monitorExecuteQueue.ts
|
||||
git commit -m "feat(scheduler): write CARRIED samples for LAST_KNOWN default status fixes #721"
|
||||
```
|
||||
|
||||
(Live behavior is verified end-to-end in Task 8 — the worker needs Redis + the cron loop, so there is no isolated script for this task.)
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Normalization chokepoint for all monitor writes
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/lib/server/controllers/monitorsController.ts` (near `CreateUpdateMonitor`, line 193)
|
||||
- Modify: `src/routes/(api)/api/v4/monitors/+server.ts:104-121`
|
||||
- Modify: `src/routes/(api)/api/v4/monitors/[monitor_tag]/+server.ts:72-78`
|
||||
|
||||
- [ ] **Step 1: Add the helper to monitorsController.ts**
|
||||
|
||||
Directly above `CreateUpdateMonitor` (line 193), add:
|
||||
|
||||
```typescript
|
||||
const VALID_DEFAULT_STATUSES = ["NONE", GC.UP, GC.DOWN, GC.DEGRADED, GC.LAST_KNOWN] as const
|
||||
|
||||
/**
|
||||
* Enforce the closed default_status value set and the LAST_KNOWN scope rule
|
||||
* (docs/adr/0006): LAST_KNOWN is only meaningful on NONE-type (Manual) monitors;
|
||||
* on any other type it silently resets to UP so the invalid combination never persists.
|
||||
* Throws on values outside the closed set.
|
||||
*/
|
||||
export const NormalizeDefaultStatus = (monitorType: string | null | undefined, defaultStatus: string | null | undefined): string => {
|
||||
const value = defaultStatus ?? "NONE"
|
||||
if (!(VALID_DEFAULT_STATUSES as readonly string[]).includes(value)) {
|
||||
throw new Error(`default_status must be one of: ${VALID_DEFAULT_STATUSES.join(", ")}`)
|
||||
}
|
||||
if (value === GC.LAST_KNOWN && monitorType !== "NONE") {
|
||||
return GC.UP
|
||||
}
|
||||
return value
|
||||
}
|
||||
```
|
||||
|
||||
(`monitorsController.ts` already imports `GC` at line 25 — no import change needed.)
|
||||
|
||||
- [ ] **Step 2: Apply it in the manage-UI path**
|
||||
|
||||
Replace `CreateUpdateMonitor` (lines 193-201):
|
||||
|
||||
```typescript
|
||||
export const CreateUpdateMonitor = async (monitor: MonitorInput): Promise<number | number[]> => {
|
||||
let monitorData = { ...monitor }
|
||||
if (monitorData.id) {
|
||||
return await db.updateMonitor(monitorData as MonitorRecord)
|
||||
} else {
|
||||
validateMonitorTag(monitorData.tag)
|
||||
return await db.insertMonitor(monitorData)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
export const CreateUpdateMonitor = async (monitor: MonitorInput): Promise<number | number[]> => {
|
||||
let monitorData = { ...monitor }
|
||||
monitorData.default_status = NormalizeDefaultStatus(monitorData.monitor_type, monitorData.default_status)
|
||||
if (monitorData.id) {
|
||||
return await db.updateMonitor(monitorData as MonitorRecord)
|
||||
} else {
|
||||
validateMonitorTag(monitorData.tag)
|
||||
return await db.insertMonitor(monitorData)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(The manage API route at `src/routes/(manage)/manage/api/+server.ts` wraps the action switch in try/catch (line 660) and surfaces thrown `Error.message` — no route change needed.)
|
||||
|
||||
- [ ] **Step 3: Apply it in v4 POST**
|
||||
|
||||
In `src/routes/(api)/api/v4/monitors/+server.ts`, add to the imports from the monitors controller (`GetMonitorsParsed` is already imported — extend that import):
|
||||
|
||||
```typescript
|
||||
import { GetMonitorsParsed, NormalizeDefaultStatus } from "$lib/server/controllers/monitorsController"
|
||||
```
|
||||
|
||||
(match the existing import line's exact shape — if `GetMonitorsParsed` is imported from a different specifier, add `NormalizeDefaultStatus` to that same line).
|
||||
|
||||
Then replace line 111:
|
||||
|
||||
```typescript
|
||||
default_status: body.default_status ?? "UP",
|
||||
```
|
||||
|
||||
with a pre-validated variable. Above the `const monitorData = {` block (line 104), insert:
|
||||
|
||||
```typescript
|
||||
let defaultStatus: string
|
||||
try {
|
||||
defaultStatus = NormalizeDefaultStatus(body.monitor_type ?? "API", body.default_status ?? "UP")
|
||||
} catch (e) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: {
|
||||
code: "BAD_REQUEST",
|
||||
message: e instanceof Error ? e.message : "Invalid default_status"
|
||||
}
|
||||
}
|
||||
return json(errorResponse, { status: 400 })
|
||||
}
|
||||
```
|
||||
|
||||
and in `monitorData` use:
|
||||
|
||||
```typescript
|
||||
default_status: defaultStatus,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Apply it in v4 PATCH**
|
||||
|
||||
In `src/routes/(api)/api/v4/monitors/[monitor_tag]/+server.ts`, the handler resolves `updateData.monitor_type` at line 78 _after_ `updateData.default_status` at line 75 — the normalization must run after BOTH are resolved. Replace line 75:
|
||||
|
||||
```typescript
|
||||
updateData.default_status = body.default_status !== undefined ? body.default_status : existingMonitor.default_status
|
||||
```
|
||||
|
||||
with (keep it in place so field ordering stays readable, but move the value through the helper after line 78):
|
||||
|
||||
```typescript
|
||||
updateData.default_status = body.default_status !== undefined ? body.default_status : existingMonitor.default_status
|
||||
```
|
||||
|
||||
…and after line 78 (`updateData.monitor_type = ...`), insert:
|
||||
|
||||
```typescript
|
||||
// Closed-set validation + LAST_KNOWN scope rule (docs/adr/0006). Runs after monitor_type
|
||||
// is resolved so a type change away from NONE auto-resets LAST_KNOWN to UP.
|
||||
try {
|
||||
updateData.default_status = NormalizeDefaultStatus(updateData.monitor_type as string, updateData.default_status as string | null)
|
||||
} catch (e) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: {
|
||||
code: "BAD_REQUEST",
|
||||
message: e instanceof Error ? e.message : "Invalid default_status"
|
||||
}
|
||||
}
|
||||
return json(errorResponse, { status: 400 })
|
||||
}
|
||||
```
|
||||
|
||||
Add `NormalizeDefaultStatus` to this file's monitors-controller import the same way as in Step 3.
|
||||
|
||||
- [ ] **Step 5: Verify with a throwaway script**
|
||||
|
||||
Create `scripts/tmp-verify-normalize.ts`:
|
||||
|
||||
```typescript
|
||||
import { NormalizeDefaultStatus } from "../src/lib/server/controllers/monitorsController"
|
||||
|
||||
const cases: Array<[string, string | null, string]> = [
|
||||
["NONE", "LAST_KNOWN", "LAST_KNOWN"], // allowed on Manual monitors
|
||||
["API", "LAST_KNOWN", "UP"], // auto-reset on any other type
|
||||
["NONE", null, "NONE"], // null → NONE
|
||||
["API", "DOWN", "DOWN"] // fixed values pass through
|
||||
]
|
||||
for (const [type, input, expected] of cases) {
|
||||
const got = NormalizeDefaultStatus(type, input)
|
||||
if (got !== expected) throw new Error(`FAIL: (${type}, ${input}) → ${got}, expected ${expected}`)
|
||||
}
|
||||
let threw = false
|
||||
try {
|
||||
NormalizeDefaultStatus("API", "MAINTENANCE")
|
||||
} catch {
|
||||
threw = true
|
||||
}
|
||||
if (!threw) throw new Error("FAIL: MAINTENANCE must be rejected")
|
||||
console.log("PASS")
|
||||
```
|
||||
|
||||
Run: `npx vite-node scripts/tmp-verify-normalize.ts`
|
||||
Expected: `PASS`. (Importing monitorsController transitively pulls in the db singleton; vite-node loads the repo `.env` automatically, so the configured `DATABASE_URL`/`REDIS_URL` satisfy it. If module side-effects still fail outside the dev process, inline the `NormalizeDefaultStatus` cases into a temporary copy instead — the function is pure.)
|
||||
|
||||
- [ ] **Step 6: Delete script, type-check, commit**
|
||||
|
||||
```bash
|
||||
rm scripts/tmp-verify-normalize.ts
|
||||
npm run check
|
||||
git add src/lib/server/controllers/monitorsController.ts "src/routes/(api)/api/v4/monitors/+server.ts" "src/routes/(api)/api/v4/monitors/[monitor_tag]/+server.ts"
|
||||
git commit -m "feat(api): enforce closed default_status set with LAST_KNOWN scope rule"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Migration — normalize legacy default_status values
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `migrations/20260607150000_normalize_default_status.ts`
|
||||
|
||||
- [ ] **Step 1: Write the migration**
|
||||
|
||||
```typescript
|
||||
import type { Knex } from "knex"
|
||||
|
||||
// Closed default_status set as of docs/adr/0006. MAINTENANCE was offered by the old
|
||||
// UI but never honored by the fill engine — it behaved exactly like "no fill", so it
|
||||
// (and any other unknown value, and NULL) normalizes to NONE, preserving behavior.
|
||||
const VALID = ["NONE", "UP", "DOWN", "DEGRADED", "LAST_KNOWN"]
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex("monitors").whereNull("default_status").update({ default_status: "NONE" })
|
||||
await knex("monitors").whereNotIn("default_status", VALID).update({ default_status: "NONE" })
|
||||
}
|
||||
|
||||
export async function down(): Promise<void> {
|
||||
// Irreversible by design: the values rewritten to NONE were dead (never honored
|
||||
// by the fill engine), so there is nothing meaningful to restore.
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it against the dev database**
|
||||
|
||||
Run: `npm run migrate`
|
||||
Expected: `Batch N run: 1 migrations` with no errors.
|
||||
|
||||
- [ ] **Step 3: Spot-check via the dev API**
|
||||
|
||||
With the dev server running (`npm run dev` if not already):
|
||||
|
||||
```bash
|
||||
curl -s 'http://localhost:3000/api/v4/monitors' -H 'Authorization: Bearer <API_KEY>' | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const r=JSON.parse(s);const bad=(r.monitors||r.data||[]).filter(m=>!['NONE','UP','DOWN','DEGRADED','LAST_KNOWN'].includes(m.default_status));console.log('invalid default_status rows:',bad.length)})"
|
||||
```
|
||||
|
||||
Expected: `invalid default_status rows: 0`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add migrations/20260607150000_normalize_default_status.ts
|
||||
git commit -m "feat(db): migrate default_status to closed value set"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Manage UI — dropdown options + callout + auto-reset
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/routes/(manage)/manage/app/monitors/[tag]/components/GeneralSettingsCard.svelte:230-247`
|
||||
|
||||
**REQUIRED SUB-SKILL for this task: `svelte-code-writer` (per CLAUDE.md, mandatory for all .svelte edits).**
|
||||
|
||||
- [ ] **Step 1: Add imports and labels**
|
||||
|
||||
In the `<script lang="ts">` block (GC is already imported at line 20), add to the imports:
|
||||
|
||||
```typescript
|
||||
import * as Alert from "$lib/components/ui/alert/index.js"
|
||||
import TriangleAlertIcon from "@lucide/svelte/icons/triangle-alert"
|
||||
```
|
||||
|
||||
and below the props destructuring add:
|
||||
|
||||
```typescript
|
||||
const defaultStatusLabels: Record<string, string> = {
|
||||
NONE: "None (show gaps as no data)",
|
||||
UP: "UP",
|
||||
DOWN: "DOWN",
|
||||
DEGRADED: "DEGRADED",
|
||||
LAST_KNOWN: "Last known status"
|
||||
}
|
||||
|
||||
// LAST_KNOWN is only valid on Manual (NONE-type) monitors; the server enforces the
|
||||
// same rule (NormalizeDefaultStatus), this effect just keeps the UI honest live.
|
||||
$effect(() => {
|
||||
if (monitor.monitor_type !== "NONE" && monitor.default_status === GC.LAST_KNOWN) {
|
||||
monitor.default_status = GC.UP
|
||||
toast.info("Default status was reset to UP — Last known status is only available for Manual monitors.")
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
(`toast` is already imported from `svelte-sonner` at line 16.)
|
||||
|
||||
- [ ] **Step 2: Replace the Default Status select**
|
||||
|
||||
Replace lines 230-247:
|
||||
|
||||
```svelte
|
||||
<Label for="monitor-default-status">Default Status</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={monitor.default_status}
|
||||
onValueChange={(v) => {
|
||||
if (v) monitor.default_status = v
|
||||
}}
|
||||
>
|
||||
<Select.Trigger id="monitor-default-status" class="w-full">
|
||||
{monitor.default_status}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="UP">UP</Select.Item>
|
||||
<Select.Item value="DOWN">DOWN</Select.Item>
|
||||
<Select.Item value="DEGRADED">DEGRADED</Select.Item>
|
||||
<Select.Item value="MAINTENANCE">MAINTENANCE</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```svelte
|
||||
<Label for="monitor-default-status">Default Status</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={monitor.default_status ?? "NONE"}
|
||||
onValueChange={(v) => {
|
||||
if (v) monitor.default_status = v
|
||||
}}
|
||||
>
|
||||
<Select.Trigger id="monitor-default-status" class="w-full">
|
||||
{defaultStatusLabels[monitor.default_status ?? "NONE"] ?? monitor.default_status}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="NONE">None (show gaps as no data)</Select.Item>
|
||||
<Select.Item value="UP">UP</Select.Item>
|
||||
<Select.Item value="DOWN">DOWN</Select.Item>
|
||||
<Select.Item value="DEGRADED">DEGRADED</Select.Item>
|
||||
{#if monitor.monitor_type === "NONE"}
|
||||
<Select.Item value="LAST_KNOWN">Last known status</Select.Item>
|
||||
{/if}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{#if monitor.default_status === GC.LAST_KNOWN}
|
||||
<Alert.Root>
|
||||
<TriangleAlertIcon />
|
||||
<Alert.Title>Last known status</Alert.Title>
|
||||
<Alert.Description>
|
||||
<p>Kener will repeat the most recent status and latency every minute until your integration sends new data.</p>
|
||||
<ul class="list-disc pl-4">
|
||||
<li>
|
||||
If your integration stops sending, the page keeps showing the last status indefinitely — Kener cannot tell "still up" from "stopped reporting". Use a Heartbeat
|
||||
monitor to catch a silent integration.
|
||||
</li>
|
||||
<li>
|
||||
Carried minutes count toward alert thresholds: a single DOWN push will trigger alerts after your failure threshold, and they stay triggered until you push a
|
||||
recovery.
|
||||
</li>
|
||||
</ul>
|
||||
</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the svelte autofixer / check**
|
||||
|
||||
Run: `npm run check`
|
||||
Expected: 0 new errors or warnings for `GeneralSettingsCard.svelte`. Also run the svelte MCP autofixer on the component if the svelte-code-writer skill instructs it.
|
||||
|
||||
- [ ] **Step 4: Visual verification**
|
||||
|
||||
With `npm run dev` running, screenshot the editor for the seeded NONE-type monitor (`earth`):
|
||||
|
||||
```bash
|
||||
npx playwright screenshot --channel chrome --color-scheme light --viewport-size "1440,900" --full-page --wait-for-timeout 3000 http://localhost:3000/manage/app/monitors/earth /tmp/lk-ui.png
|
||||
```
|
||||
|
||||
(Authed page — if it renders the login screen, follow the storage-state cookie recipe in the project memory `kener-ui-verification-recipe`, or verify manually in the browser.)
|
||||
Expected: dropdown shows the five options ("Last known status" present because earth is Manual/NONE type, "MAINTENANCE" gone); selecting "Last known status" reveals the callout.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add "src/routes/(manage)/manage/app/monitors/[tag]/components/GeneralSettingsCard.svelte"
|
||||
git commit -m "feat(manage): Last known status option with callout in Default Status dropdown"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Docs — ADR cross-link + user documentation
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/adr/0005-alerts-evaluate-alert-visible-samples.md` (append one sentence)
|
||||
- Modify: `src/routes/(docs)/docs/content/v4/monitors/overview.md` (Default Status section)
|
||||
|
||||
**REQUIRED SUB-SKILL for the `src/routes/(docs)/docs/content/` edit: `documentation-writer` (per CLAUDE.md, mandatory for docs content).**
|
||||
|
||||
- [ ] **Step 1: Amend ADR 0005**
|
||||
|
||||
Append this sentence to the end of the first paragraph of `docs/adr/0005-alerts-evaluate-alert-visible-samples.md` (after "...remain invisible to alerting."):
|
||||
|
||||
```
|
||||
Amended by ADR 0006: last-known-status fill (`CARRIED`) later joined the alert-visible set under the same invariant.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Document Last Known Status in the monitor docs**
|
||||
|
||||
`src/routes/(docs)/docs/content/v4/monitors/overview.md` does not mention `default_status` today (verified) — add a new "Default Status" section to it. Following the documentation-writer skill's conventions, document:
|
||||
|
||||
- The five values: `NONE`, `UP`, `DOWN`, `DEGRADED`, `LAST_KNOWN`.
|
||||
- `LAST_KNOWN` is only accepted for Manual (`NONE`-type) monitors; on any other type the API resets it to `UP`.
|
||||
- Behavior: every minute without new data, Kener writes a `CARRIED` sample repeating the most recent alert-visible sample (status and latency). Carry never expires and starts at the next tick after the setting is saved (no backfill).
|
||||
- The two warnings from the UI callout (stale-forever if the integration goes silent → use a Heartbeat monitor; carried minutes count toward alert thresholds and alerts only resolve on a pushed recovery).
|
||||
- A curl example mirroring #721's flow:
|
||||
|
||||
```bash
|
||||
curl -X PATCH 'https://status.example.com/api/v4/monitors/my-service/data/{current_unix_minute}' \
|
||||
-H 'Authorization: Bearer <api-key>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"status": "DOWN", "latency": 100}'
|
||||
# With Default Status = Last known status, the monitor stays DOWN until you push UP.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/adr/0005-alerts-evaluate-alert-visible-samples.md "src/routes/(docs)/docs/content/v4/monitors/overview.md"
|
||||
git commit -m "docs: document Last known status default and amend ADR 0005"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: End-to-end verification against the dev server
|
||||
|
||||
**Files:** none (verification only; uses the running `npm run dev` with Redis + Postgres up)
|
||||
|
||||
- [ ] **Step 1: Create a throwaway NONE monitor with LAST_KNOWN**
|
||||
|
||||
```bash
|
||||
curl -s -X POST 'http://localhost:3000/api/v4/monitors' \
|
||||
-H 'Authorization: Bearer <API_KEY>' -H 'Content-Type: application/json' \
|
||||
--data '{"tag":"lk-e2e","name":"LK E2E","monitor_type":"NONE","default_status":"LAST_KNOWN","cron":"* * * * *"}'
|
||||
```
|
||||
|
||||
Expected: 201, monitor JSON with `"default_status":"LAST_KNOWN"`.
|
||||
|
||||
- [ ] **Step 2: Confirm no fill before any push**
|
||||
|
||||
Wait ~70 seconds (one scheduler tick), then:
|
||||
|
||||
```bash
|
||||
NOW=$(date -u +%s); curl -s "http://localhost:3000/api/v4/monitors/lk-e2e/data?start_ts=$((NOW-300))&end_ts=$NOW" -H 'Authorization: Bearer <API_KEY>'
|
||||
```
|
||||
|
||||
Expected: `{"data":[]}` — nothing to carry yet (never-pushed monitors stay no-data).
|
||||
|
||||
- [ ] **Step 3: Push DOWN once, watch CARRIED rows appear**
|
||||
|
||||
```bash
|
||||
NOW=$(date -u +%s)
|
||||
curl -s -X PATCH "http://localhost:3000/api/v4/monitors/lk-e2e/data/$NOW" \
|
||||
-H 'Authorization: Bearer <API_KEY>' -H 'Content-Type: application/json' \
|
||||
--data '{"status":"DOWN","latency":2201}'
|
||||
```
|
||||
|
||||
Wait ~130 seconds (two ticks), then re-run the Step 2 range query.
|
||||
Expected: one `"type":"MANUAL"` DOWN row at the pushed minute, followed by `"type":"CARRIED"` rows with `"status":"DOWN","latency":2201` for each subsequent minute.
|
||||
|
||||
- [ ] **Step 4: Push recovery, confirm carry follows**
|
||||
|
||||
Repeat Step 3's PATCH with `{"status":"UP","latency":5}`. Wait ~70s.
|
||||
Expected: subsequent CARRIED rows are `UP` with latency 5. The status page (`http://localhost:3000`) shows the monitor UP with the red DOWN window in today's bar.
|
||||
|
||||
- [ ] **Step 5: Verify the type-change auto-reset**
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH 'http://localhost:3000/api/v4/monitors/lk-e2e' \
|
||||
-H 'Authorization: Bearer <API_KEY>' -H 'Content-Type: application/json' \
|
||||
--data '{"monitor_type":"API","type_data":{"url":"https://example.com","timeout":5000}}'
|
||||
```
|
||||
|
||||
Expected: 200 with `"default_status":"UP"` in the response (LAST_KNOWN auto-reset because the type left NONE). Then verify rejection:
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH 'http://localhost:3000/api/v4/monitors/lk-e2e' \
|
||||
-H 'Authorization: Bearer <API_KEY>' -H 'Content-Type: application/json' \
|
||||
--data '{"default_status":"MAINTENANCE"}'
|
||||
```
|
||||
|
||||
Expected: 400 with `default_status must be one of: NONE, UP, DOWN, DEGRADED, LAST_KNOWN`.
|
||||
|
||||
- [ ] **Step 6: Clean up the test monitor**
|
||||
|
||||
The v4 monitor route has no DELETE handler (only GET/PATCH), so delete via the manage API action the dashboard uses, or from the UI at `http://localhost:3000/manage/app/monitors/lk-e2e` (Danger Zone → Delete). Verify it is gone from `http://localhost:3000/`.
|
||||
|
||||
- [ ] **Step 7: Final gate**
|
||||
|
||||
Run: `npm run check && npm run prettify`
|
||||
Expected: clean check; prettify produces no diff beyond the files already touched (re-commit formatting if it does).
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- **Spec coverage:** Q1 dropdown (Task 6), Q2 MAINTENANCE migration + closed set (Tasks 4, 5, 6), Q3 carry source (Task 2), Q4 CARRIED type + whitelist (Task 1), Q5 status+latency payload (Task 3), Q6/Q7 NONE-only + auto-reset (Tasks 4, 6), Q8 no expiry (no code — absence is the feature; documented in Task 7), Q9 tick-forward/no-backfill (no code — the engine only writes at `ts`; verified in Task 8 Step 2-3), Q10 alert consequences (Task 1 whitelist + existing evaluator, no further code), Q11 callout copy (Task 6).
|
||||
- **Known non-goals:** no staleness cap, no backfill, no purge on disable, no change to `CloneMonitor` (it copies `monitor_type` + `default_status` together from an already-normalized source, so the pair stays valid).
|
||||
- **Pre-existing race left untouched (deliberate):** a same-minute tick can overwrite a just-pushed MANUAL row's type via the response-queue upsert; this exists today for DEFAULT fill and is orthogonal to this change.
|
||||
+47
-3
@@ -7,13 +7,45 @@ const databaseURLParts = databaseURL.split("://");
|
||||
const databaseType = databaseURLParts[0];
|
||||
const databasePath = databaseURLParts[1];
|
||||
|
||||
const intFromEnv = (name: string, fallback: number): number => {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined) return fallback;
|
||||
const parsed = parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
};
|
||||
|
||||
// TCP keepalive on pooled connections, on by default. Cloud networks (Railway,
|
||||
// Docker Swarm overlays, k8s) silently drop idle TCP connections; without
|
||||
// keepalive the pool keeps handing out dead sockets after an idle period or a
|
||||
// database restart. See docs/adr/0003-fail-fast-self-healing-db-pool.md.
|
||||
const keepAliveEnabled = process.env.DATABASE_KEEPALIVE !== "false";
|
||||
|
||||
// Pool defaults deviate from knex's on purpose:
|
||||
// - min 0: knex's min 2 connections are never reaped, so they are exactly the
|
||||
// ones that go stale and wedge the app until a manual restart
|
||||
// - 15s acquire/create timeouts: fail fast instead of hanging requests for
|
||||
// knex's default 60s during a database outage
|
||||
// Tarn requires max >= 1 and min <= max; clamp so a bad env value can not
|
||||
// produce a pool that fails every acquire
|
||||
const poolMax = Math.max(1, intFromEnv("DATABASE_POOL_MAX", 10));
|
||||
const poolMin = Math.min(intFromEnv("DATABASE_POOL_MIN", 0), poolMax);
|
||||
const pool = {
|
||||
min: poolMin,
|
||||
max: poolMax,
|
||||
idleTimeoutMillis: intFromEnv("DATABASE_IDLE_TIMEOUT_MS", 30000),
|
||||
createTimeoutMillis: intFromEnv("DATABASE_CREATE_TIMEOUT_MS", 15000),
|
||||
};
|
||||
const acquireConnectionTimeout = intFromEnv("DATABASE_ACQUIRE_TIMEOUT_MS", 15000);
|
||||
|
||||
interface KnexConfig {
|
||||
migrations: { directory: string };
|
||||
seeds: { directory: string };
|
||||
databaseType: string;
|
||||
client?: string;
|
||||
connection?: string | { filename: string };
|
||||
connection?: string | { filename: string } | Record<string, unknown>;
|
||||
useNullAsDefault?: boolean;
|
||||
pool?: typeof pool;
|
||||
acquireConnectionTimeout?: number;
|
||||
}
|
||||
|
||||
const knexOb: KnexConfig = {
|
||||
@@ -25,6 +57,7 @@ const knexOb: KnexConfig = {
|
||||
},
|
||||
databaseType,
|
||||
};
|
||||
console.log(`Configuring database with type ${databaseType}`);
|
||||
if (databaseType === "sqlite") {
|
||||
knexOb.client = "better-sqlite3";
|
||||
knexOb.connection = {
|
||||
@@ -33,10 +66,21 @@ if (databaseType === "sqlite") {
|
||||
knexOb.useNullAsDefault = true;
|
||||
} else if (databaseType === "postgresql") {
|
||||
knexOb.client = "pg";
|
||||
knexOb.connection = databaseURL;
|
||||
knexOb.connection = {
|
||||
connectionString: databaseURL,
|
||||
keepAlive: keepAliveEnabled,
|
||||
};
|
||||
knexOb.pool = pool;
|
||||
knexOb.acquireConnectionTimeout = acquireConnectionTimeout;
|
||||
} else if (databaseType === "mysql") {
|
||||
knexOb.client = "mysql2";
|
||||
knexOb.connection = databaseURL;
|
||||
knexOb.connection = {
|
||||
uri: databaseURL,
|
||||
enableKeepAlive: keepAliveEnabled,
|
||||
keepAliveInitialDelay: 10000,
|
||||
};
|
||||
knexOb.pool = pool;
|
||||
knexOb.acquireConnectionTimeout = acquireConnectionTimeout;
|
||||
} else {
|
||||
console.error("Invalid database type");
|
||||
process.exit(1);
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema
|
||||
.createTable("monitoring_data", (table) => {
|
||||
if (!(await knex.schema.hasTable("monitoring_data"))) {
|
||||
await knex.schema.createTable("monitoring_data", (table) => {
|
||||
table.string("monitor_tag", 255).notNullable();
|
||||
table.integer("timestamp").notNullable();
|
||||
table.text("status");
|
||||
table.float("latency", 8, 2);
|
||||
table.text("type");
|
||||
table.primary(["monitor_tag", "timestamp"]);
|
||||
})
|
||||
// Create monitor_alerts table
|
||||
.createTable("monitor_alerts", (table) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable("monitor_alerts"))) {
|
||||
await knex.schema.createTable("monitor_alerts", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.string("monitor_tag", 255).notNullable();
|
||||
table.string("monitor_status", 255).notNullable();
|
||||
@@ -20,18 +22,29 @@ export async function up(knex: Knex): Promise<void> {
|
||||
table.integer("incident_number").defaultTo(0);
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
})
|
||||
// Add index to monitor_alerts table
|
||||
.raw("CREATE INDEX idx_monitor_tag_created_at ON monitor_alerts (monitor_tag, created_at)")
|
||||
.createTable("site_data", (table) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Add index (IF NOT EXISTS not supported by all DBs, so use try/catch)
|
||||
try {
|
||||
await knex.schema.raw("CREATE INDEX idx_monitor_tag_created_at ON monitor_alerts (monitor_tag, created_at)");
|
||||
} catch (_e) {
|
||||
// Index already exists
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable("site_data"))) {
|
||||
await knex.schema.createTable("site_data", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.string("key", 255).notNullable().unique();
|
||||
table.text("value").notNullable();
|
||||
table.string("data_type", 255).notNullable();
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
})
|
||||
.createTable("monitors", (table) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable("monitors"))) {
|
||||
await knex.schema.createTable("monitors", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.string("tag", 255).notNullable().unique();
|
||||
table.string("name", 255).notNullable().unique();
|
||||
@@ -50,8 +63,11 @@ export async function up(knex: Knex): Promise<void> {
|
||||
table.string("include_degraded_in_downtime", 255).defaultTo("NO");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
})
|
||||
.createTable("triggers", (table) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable("triggers"))) {
|
||||
await knex.schema.createTable("triggers", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.string("name", 255).notNullable().unique();
|
||||
table.string("trigger_type", 255);
|
||||
@@ -60,8 +76,11 @@ export async function up(knex: Knex): Promise<void> {
|
||||
table.text("trigger_meta");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
})
|
||||
.createTable("users", (table) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable("users"))) {
|
||||
await knex.schema.createTable("users", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.string("email", 255).notNullable().unique();
|
||||
table.string("name", 255).notNullable();
|
||||
@@ -71,8 +90,11 @@ export async function up(knex: Knex): Promise<void> {
|
||||
table.string("role", 255).defaultTo("user");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
})
|
||||
.createTable("api_keys", (table) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable("api_keys"))) {
|
||||
await knex.schema.createTable("api_keys", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.string("name", 255).notNullable().unique();
|
||||
table.string("hashed_key", 255).notNullable().unique();
|
||||
@@ -80,8 +102,11 @@ export async function up(knex: Knex): Promise<void> {
|
||||
table.string("status", 255).defaultTo("ACTIVE");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
})
|
||||
.createTable("incidents", (table) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable("incidents"))) {
|
||||
await knex.schema.createTable("incidents", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.string("title", 255).notNullable();
|
||||
table.integer("start_date_time").notNullable();
|
||||
@@ -90,8 +115,11 @@ export async function up(knex: Knex): Promise<void> {
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
table.string("status", 255).defaultTo("ACTIVE");
|
||||
table.string("state", 255).defaultTo("INVESTIGATING");
|
||||
})
|
||||
.createTable("incident_monitors", (table) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable("incident_monitors"))) {
|
||||
await knex.schema.createTable("incident_monitors", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.string("monitor_tag", 255).notNullable();
|
||||
table.string("monitor_impact", 255);
|
||||
@@ -99,8 +127,11 @@ export async function up(knex: Knex): Promise<void> {
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
table.integer("incident_id").notNullable();
|
||||
table.unique(["monitor_tag", "incident_id"]);
|
||||
})
|
||||
.createTable("incident_comments", (table) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable("incident_comments"))) {
|
||||
await knex.schema.createTable("incident_comments", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.text("comment").notNullable();
|
||||
table.integer("incident_id").notNullable();
|
||||
@@ -110,6 +141,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
table.string("status", 255).defaultTo("ACTIVE");
|
||||
table.string("state", 255).defaultTo("INVESTIGATING");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable("incidents", function (table) {
|
||||
table.text("incident_type").defaultTo("INCIDENT");
|
||||
});
|
||||
const hasCol = await knex.schema.hasColumn("incidents", "incident_type");
|
||||
if (!hasCol) {
|
||||
await knex.schema.alterTable("incidents", function (table) {
|
||||
table.text("incident_type").defaultTo("INCIDENT");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable("incidents", function (table) {
|
||||
table.text("incident_source").defaultTo("DASHBOARD");
|
||||
});
|
||||
const hasCol = await knex.schema.hasColumn("incidents", "incident_source");
|
||||
if (!hasCol) {
|
||||
await knex.schema.alterTable("incidents", function (table) {
|
||||
table.text("incident_source").defaultTo("DASHBOARD");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasTable("invitations")) return;
|
||||
|
||||
await knex.schema.createTable("invitations", (table) => {
|
||||
// Primary key
|
||||
table.increments("id").primary();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema
|
||||
.createTable("subscribers", (table) => {
|
||||
if (!(await knex.schema.hasTable("subscribers"))) {
|
||||
await knex.schema.createTable("subscribers", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.string("subscriber_send").notNullable();
|
||||
table.text("subscriber_meta").nullable();
|
||||
@@ -16,8 +16,11 @@ export async function up(knex: Knex): Promise<void> {
|
||||
|
||||
// Add index on subscriber_send for better query performance
|
||||
table.index(["subscriber_send"]);
|
||||
})
|
||||
.createTable("subscriptions", (table) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable("subscriptions"))) {
|
||||
await knex.schema.createTable("subscriptions", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.integer("subscriber_id").unsigned().notNullable();
|
||||
table.string("subscriptions_status").notNullable();
|
||||
@@ -32,8 +35,11 @@ export async function up(knex: Knex): Promise<void> {
|
||||
|
||||
// Add index to optimize queries filtering by status and monitors
|
||||
table.index(["subscriptions_status", "subscriptions_monitors"]);
|
||||
})
|
||||
.createTable("subscription_triggers", (table) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable("subscription_triggers"))) {
|
||||
await knex.schema.createTable("subscription_triggers", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.string("subscription_trigger_type").notNullable().unique();
|
||||
table.string("subscription_trigger_status").notNullable();
|
||||
@@ -41,6 +47,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
table.datetime("created_at").defaultTo(knex.fn.now());
|
||||
table.datetime("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
}
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasTable("images")) return;
|
||||
|
||||
await knex.schema.createTable("images", (table) => {
|
||||
table.string("id", 32).primary(); // nanoid generated ID with prefix
|
||||
table.text("data").notNullable(); // base64 encoded image data
|
||||
|
||||
@@ -2,37 +2,49 @@ import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// Create pages table
|
||||
await knex.schema.createTable("pages", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.string("page_path", 255).notNullable().unique(); // e.g., "/", "/api", "/infrastructure"
|
||||
table.string("page_title", 255).notNullable();
|
||||
table.string("page_header", 255);
|
||||
table.string("page_subheader", 255);
|
||||
table.string("page_logo", 255);
|
||||
table.text("page_settings_json"); // JSON settings for the page
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
if (!(await knex.schema.hasTable("pages"))) {
|
||||
await knex.schema.createTable("pages", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.string("page_path", 255).notNullable().unique(); // e.g., "/", "/api", "/infrastructure"
|
||||
table.string("page_title", 255).notNullable();
|
||||
table.string("page_header", 255);
|
||||
table.string("page_subheader", 255);
|
||||
table.string("page_logo", 255);
|
||||
table.text("page_settings_json"); // JSON settings for the page
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
// Create pages_monitors junction table
|
||||
await knex.schema.createTable("pages_monitors", (table) => {
|
||||
table.integer("page_id").unsigned().notNullable();
|
||||
table.string("monitor_tag", 255).notNullable();
|
||||
table.text("monitor_settings_json"); // JSON settings for monitor on this page (e.g., order, visibility)
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
if (!(await knex.schema.hasTable("pages_monitors"))) {
|
||||
await knex.schema.createTable("pages_monitors", (table) => {
|
||||
table.integer("page_id").unsigned().notNullable();
|
||||
table.string("monitor_tag", 255).notNullable();
|
||||
table.text("monitor_settings_json"); // JSON settings for monitor on this page (e.g., order, visibility)
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
// Composite primary key
|
||||
table.primary(["page_id", "monitor_tag"]);
|
||||
// Composite primary key
|
||||
table.primary(["page_id", "monitor_tag"]);
|
||||
|
||||
// Foreign key constraints
|
||||
table.foreign("page_id").references("id").inTable("pages").onDelete("CASCADE");
|
||||
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
|
||||
});
|
||||
// Foreign key constraints
|
||||
table.foreign("page_id").references("id").inTable("pages").onDelete("CASCADE");
|
||||
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
|
||||
});
|
||||
}
|
||||
|
||||
// Add index for faster lookups
|
||||
await knex.schema.raw("CREATE INDEX idx_pages_monitors_page_id ON pages_monitors (page_id)");
|
||||
await knex.schema.raw("CREATE INDEX idx_pages_monitors_monitor_tag ON pages_monitors (monitor_tag)");
|
||||
// Add indexes (safe to fail if they already exist)
|
||||
try {
|
||||
await knex.schema.raw("CREATE INDEX idx_pages_monitors_page_id ON pages_monitors (page_id)");
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
try {
|
||||
await knex.schema.raw("CREATE INDEX idx_pages_monitors_monitor_tag ON pages_monitors (monitor_tag)");
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
|
||||
@@ -1,64 +1,69 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// Create maintenances table - defines maintenance schedules using iCalendar RRULE
|
||||
// RRULE examples:
|
||||
// - ONE_TIME: FREQ=MINUTELY;COUNT=1 (single occurrence)
|
||||
// - RECURRING: FREQ=WEEKLY;BYDAY=SU;BYHOUR=2;BYMINUTE=0 (every Sunday at 2 AM)
|
||||
// Reference: http://www.kanzaki.com/docs/ical/rrule.html
|
||||
await knex.schema.createTable("maintenances", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.string("title", 255).notNullable();
|
||||
table.text("description").nullable(); // Maintenance details/description
|
||||
table.integer("start_date_time").notNullable(); // Unix timestamp - when the first occurrence starts
|
||||
table.string("rrule", 500).notNullable(); // iCalendar RRULE string (e.g., FREQ=WEEKLY;BYDAY=SU)
|
||||
table.integer("duration_seconds").notNullable(); // Duration of each maintenance window in seconds
|
||||
table.string("status", 50).notNullable().defaultTo("ACTIVE"); // ACTIVE or INACTIVE
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
if (!(await knex.schema.hasTable("maintenances"))) {
|
||||
await knex.schema.createTable("maintenances", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.string("title", 255).notNullable();
|
||||
table.text("description").nullable();
|
||||
table.integer("start_date_time").notNullable();
|
||||
table.string("rrule", 500).notNullable();
|
||||
table.integer("duration_seconds").notNullable();
|
||||
table.string("status", 50).notNullable().defaultTo("ACTIVE");
|
||||
table.string("is_global", 15).notNullable().defaultTo("YES");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
// Create maintenance_monitors junction table - links monitors to maintenance schedules
|
||||
await knex.schema.createTable("maintenance_monitors", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.integer("maintenance_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());
|
||||
if (!(await knex.schema.hasTable("maintenance_monitors"))) {
|
||||
await knex.schema.createTable("maintenance_monitors", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.integer("maintenance_id").unsigned().notNullable();
|
||||
table.string("monitor_tag", 255).notNullable();
|
||||
table.string("monitor_impact").defaultTo("MAINTENANCE").notNullable();
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
// Foreign key constraints
|
||||
table.foreign("maintenance_id").references("id").inTable("maintenances").onDelete("CASCADE");
|
||||
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
|
||||
table.foreign("maintenance_id").references("id").inTable("maintenances").onDelete("CASCADE");
|
||||
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
|
||||
|
||||
// Unique constraint to prevent duplicate monitor assignments
|
||||
table.unique(["maintenance_id", "monitor_tag"]);
|
||||
});
|
||||
table.unique(["maintenance_id", "monitor_tag"]);
|
||||
});
|
||||
}
|
||||
|
||||
// Create maintenances_events table - actual maintenance occurrences (generated by job)
|
||||
await knex.schema.createTable("maintenances_events", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.integer("maintenance_id").unsigned().notNullable();
|
||||
table.integer("start_date_time").notNullable(); // Unix timestamp
|
||||
table.integer("end_date_time").notNullable(); // Unix timestamp
|
||||
table.string("status", 50).notNullable().defaultTo("SCHEDULED"); // SCHEDULED, IN_PROGRESS, COMPLETED, CANCELLED
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
if (!(await knex.schema.hasTable("maintenances_events"))) {
|
||||
await knex.schema.createTable("maintenances_events", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.integer("maintenance_id").unsigned().notNullable();
|
||||
table.integer("start_date_time").notNullable();
|
||||
table.integer("end_date_time").notNullable();
|
||||
table.string("status", 50).notNullable().defaultTo("SCHEDULED");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
// Foreign key constraint
|
||||
table.foreign("maintenance_id").references("id").inTable("maintenances").onDelete("CASCADE");
|
||||
});
|
||||
table.foreign("maintenance_id").references("id").inTable("maintenances").onDelete("CASCADE");
|
||||
});
|
||||
}
|
||||
|
||||
// Add indexes for faster lookups
|
||||
await knex.schema.raw("CREATE INDEX idx_maintenances_status ON maintenances (status)");
|
||||
await knex.schema.raw("CREATE INDEX idx_maintenances_start_time ON maintenances (start_date_time)");
|
||||
await knex.schema.raw(
|
||||
// Add indexes (safe to fail if they already exist)
|
||||
const indexes = [
|
||||
"CREATE INDEX idx_maintenances_status ON maintenances (status)",
|
||||
"CREATE INDEX idx_maintenances_start_time ON maintenances (start_date_time)",
|
||||
"CREATE INDEX idx_maintenance_monitors_maintenance_id ON maintenance_monitors (maintenance_id)",
|
||||
);
|
||||
await knex.schema.raw("CREATE INDEX idx_maintenance_monitors_monitor_tag ON maintenance_monitors (monitor_tag)");
|
||||
await knex.schema.raw("CREATE INDEX idx_maintenances_events_maintenance_id ON maintenances_events (maintenance_id)");
|
||||
await knex.schema.raw("CREATE INDEX idx_maintenances_events_status ON maintenances_events (status)");
|
||||
await knex.schema.raw("CREATE INDEX idx_maintenances_events_start_time ON maintenances_events (start_date_time)");
|
||||
await knex.schema.raw("CREATE INDEX idx_maintenances_events_end_time ON maintenances_events (end_date_time)");
|
||||
"CREATE INDEX idx_maintenance_monitors_monitor_tag ON maintenance_monitors (monitor_tag)",
|
||||
"CREATE INDEX idx_maintenances_events_maintenance_id ON maintenances_events (maintenance_id)",
|
||||
"CREATE INDEX idx_maintenances_events_status ON maintenances_events (status)",
|
||||
"CREATE INDEX idx_maintenances_events_start_time ON maintenances_events (start_date_time)",
|
||||
"CREATE INDEX idx_maintenances_events_end_time ON maintenances_events (end_date_time)",
|
||||
];
|
||||
for (const sql of indexes) {
|
||||
try {
|
||||
await knex.schema.raw(sql);
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// Remove unique constraint from monitors.name
|
||||
await knex.schema.alterTable("monitors", (table) => {
|
||||
table.dropUnique(["name"]);
|
||||
});
|
||||
// Remove unique constraint from monitors.name (safe to fail if already dropped)
|
||||
try {
|
||||
await knex.schema.alterTable("monitors", (table) => {
|
||||
table.dropUnique(["name"]);
|
||||
});
|
||||
} catch (_e) {
|
||||
// Constraint already removed
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable("monitors", (table) => {
|
||||
table.string("is_hidden").defaultTo("NO").notNullable();
|
||||
});
|
||||
const hasCol = await knex.schema.hasColumn("monitors", "is_hidden");
|
||||
if (!hasCol) {
|
||||
await knex.schema.alterTable("monitors", (table) => {
|
||||
table.string("is_hidden").defaultTo("NO").notNullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable("monitors", (table) => {
|
||||
table.text("monitor_settings_json").nullable();
|
||||
});
|
||||
const hasCol = await knex.schema.hasColumn("monitors", "monitor_settings_json");
|
||||
if (!hasCol) {
|
||||
await knex.schema.alterTable("monitors", (table) => {
|
||||
table.text("monitor_settings_json").nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable("maintenance_monitors", (table) => {
|
||||
table.string("monitor_impact").defaultTo("MAINTENANCE").notNullable();
|
||||
});
|
||||
if (!(await knex.schema.hasTable("maintenance_monitors"))) return;
|
||||
const hasCol = await knex.schema.hasColumn("maintenance_monitors", "monitor_impact");
|
||||
if (!hasCol) {
|
||||
await knex.schema.alterTable("maintenance_monitors", (table) => {
|
||||
table.string("monitor_impact").defaultTo("MAINTENANCE").notNullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasTable("maintenance_monitors"))) return;
|
||||
await knex.schema.alterTable("maintenance_monitors", (table) => {
|
||||
table.dropColumn("monitor_impact");
|
||||
});
|
||||
|
||||
@@ -2,42 +2,54 @@ import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// Create monitor_alerts_config table
|
||||
await knex.schema.createTable("monitor_alerts_config", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.string("monitor_tag", 255).notNullable();
|
||||
table.string("alert_for", 50).notNullable(); // STATUS, LATENCY, UPTIME
|
||||
table.string("alert_value", 255).notNullable(); // DOWN, DEGRADED, or numeric value like "1000" or "99"
|
||||
table.integer("failure_threshold").notNullable().defaultTo(1);
|
||||
table.integer("success_threshold").notNullable().defaultTo(1);
|
||||
table.text("alert_description");
|
||||
table.string("create_incident", 10).notNullable().defaultTo("NO"); // YES or NO
|
||||
table.string("is_active", 10).notNullable().defaultTo("YES"); // YES or NO
|
||||
table.string("severity", 50).notNullable().defaultTo("WARNING"); // CRITICAL or WARNING
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
if (!(await knex.schema.hasTable("monitor_alerts_config"))) {
|
||||
await knex.schema.createTable("monitor_alerts_config", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.string("monitor_tag", 255).notNullable();
|
||||
table.string("alert_for", 50).notNullable(); // STATUS, LATENCY, UPTIME
|
||||
table.string("alert_value", 255).notNullable(); // DOWN, DEGRADED, or numeric value like "1000" or "99"
|
||||
table.integer("failure_threshold").notNullable().defaultTo(1);
|
||||
table.integer("success_threshold").notNullable().defaultTo(1);
|
||||
table.text("alert_description");
|
||||
table.string("create_incident", 10).notNullable().defaultTo("NO"); // YES or NO
|
||||
table.string("is_active", 10).notNullable().defaultTo("YES"); // YES or NO
|
||||
table.string("severity", 50).notNullable().defaultTo("WARNING"); // CRITICAL or WARNING
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
// Foreign key to monitors table
|
||||
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
|
||||
});
|
||||
// Foreign key to monitors table
|
||||
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
|
||||
});
|
||||
}
|
||||
|
||||
// Create index for faster lookups
|
||||
await knex.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
|
||||
await knex.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
|
||||
// Create indexes (safe to fail if they already exist)
|
||||
try {
|
||||
await knex.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
try {
|
||||
await knex.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
|
||||
// Create monitor_alerts_config_triggers junction table
|
||||
await knex.schema.createTable("monitor_alerts_config_triggers", (table) => {
|
||||
table.integer("monitor_alerts_id").unsigned().notNullable();
|
||||
table.integer("trigger_id").unsigned().notNullable();
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
if (!(await knex.schema.hasTable("monitor_alerts_config_triggers"))) {
|
||||
await knex.schema.createTable("monitor_alerts_config_triggers", (table) => {
|
||||
table.integer("monitor_alerts_id").unsigned().notNullable();
|
||||
table.integer("trigger_id").unsigned().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", "trigger_id"]);
|
||||
// Composite primary key
|
||||
table.primary(["monitor_alerts_id", "trigger_id"]);
|
||||
|
||||
// Foreign keys
|
||||
table.foreign("monitor_alerts_id").references("id").inTable("monitor_alerts_config").onDelete("CASCADE");
|
||||
table.foreign("trigger_id").references("id").inTable("triggers").onDelete("CASCADE");
|
||||
});
|
||||
// Foreign keys
|
||||
table.foreign("monitor_alerts_id").references("id").inTable("monitor_alerts_config").onDelete("CASCADE");
|
||||
table.foreign("trigger_id").references("id").inTable("triggers").onDelete("CASCADE");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
|
||||
@@ -1,19 +1,56 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.createTable("monitor_alerts_v2", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.integer("config_id").references("id").inTable("monitor_alerts_config").notNullable().onDelete("CASCADE");
|
||||
table.integer("incident_id").references("id").inTable("incidents").nullable().onDelete("SET NULL");
|
||||
table.string("alert_status", 255).notNullable();
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
if (!(await knex.schema.hasTable("monitor_alerts_v2"))) {
|
||||
await knex.schema.createTable("monitor_alerts_v2", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.integer("config_id").unsigned().notNullable();
|
||||
table.integer("incident_id").unsigned().nullable();
|
||||
table.string("alert_status", 255).notNullable();
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
// Add index for faster queries on config_id and alert_status
|
||||
table.index(["config_id", "alert_status"]);
|
||||
});
|
||||
// Add index for faster queries on config_id and alert_status
|
||||
table.index(["config_id", "alert_status"]);
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure config_id is unsigned (fix for MySQL users who had signed int from a prior failed run)
|
||||
try {
|
||||
await knex.schema.alterTable("monitor_alerts_v2", (table) => {
|
||||
table.integer("config_id").unsigned().notNullable().alter();
|
||||
});
|
||||
} catch (_e) {
|
||||
/* column may already be correct */
|
||||
}
|
||||
|
||||
// Ensure incident_id is unsigned
|
||||
try {
|
||||
await knex.schema.alterTable("monitor_alerts_v2", (table) => {
|
||||
table.integer("incident_id").unsigned().nullable().alter();
|
||||
});
|
||||
} catch (_e) {
|
||||
/* column may already be correct */
|
||||
}
|
||||
|
||||
// Add foreign key constraints (skip if they already exist)
|
||||
try {
|
||||
await knex.schema.alterTable("monitor_alerts_v2", (table) => {
|
||||
table.foreign("config_id").references("id").inTable("monitor_alerts_config").onDelete("CASCADE");
|
||||
});
|
||||
} catch (_e) {
|
||||
/* foreign key may already exist */
|
||||
}
|
||||
|
||||
try {
|
||||
await knex.schema.alterTable("monitor_alerts_v2", (table) => {
|
||||
table.foreign("incident_id").references("id").inTable("incidents").onDelete("SET NULL");
|
||||
});
|
||||
} catch (_e) {
|
||||
/* foreign key may already exist */
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTable("monitor_alerts_v2");
|
||||
await knex.schema.dropTableIfExists("monitor_alerts_v2");
|
||||
}
|
||||
|
||||
@@ -2,93 +2,138 @@ import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// 1. Create subscriber_users table - the actual user identity
|
||||
await knex.schema.createTable("subscriber_users", (table) => {
|
||||
table.increments("id").primary();
|
||||
if (!(await knex.schema.hasTable("subscriber_users"))) {
|
||||
await knex.schema.createTable("subscriber_users", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.string("email", 255).notNullable().unique();
|
||||
table.string("status", 20).notNullable().defaultTo("PENDING");
|
||||
table.string("verification_code", 10).nullable();
|
||||
table.timestamp("verification_expires_at").nullable();
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
table.index(["status"]);
|
||||
table.index(["email"]);
|
||||
});
|
||||
}
|
||||
|
||||
// Email is the primary identifier for users
|
||||
table.string("email", 255).notNullable().unique();
|
||||
// 2. Create subscriber_methods table
|
||||
if (!(await knex.schema.hasTable("subscriber_methods"))) {
|
||||
await knex.schema.createTable("subscriber_methods", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.integer("subscriber_user_id").unsigned().notNullable();
|
||||
table.string("method_type", 50).notNullable();
|
||||
table.string("method_value", 500).notNullable();
|
||||
table.string("status", 20).notNullable().defaultTo("ACTIVE");
|
||||
table.text("meta").nullable();
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
// User status: PENDING (awaiting verification), ACTIVE, INACTIVE
|
||||
table.string("status", 20).notNullable().defaultTo("PENDING");
|
||||
// Add indexes, unique constraints, and foreign keys for subscriber_methods (idempotent)
|
||||
try {
|
||||
await knex.schema.alterTable("subscriber_methods", (table) => {
|
||||
table.index(["subscriber_user_id"]);
|
||||
});
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
try {
|
||||
await knex.schema.alterTable("subscriber_methods", (table) => {
|
||||
table.index(["method_type"]);
|
||||
});
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
try {
|
||||
await knex.schema.alterTable("subscriber_methods", (table) => {
|
||||
table.index(["status"]);
|
||||
});
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
try {
|
||||
await knex.schema.alterTable("subscriber_methods", (table) => {
|
||||
table.unique(["subscriber_user_id", "method_type", "method_value"], {
|
||||
indexName: "sub_methods_user_type_value_unique",
|
||||
});
|
||||
});
|
||||
} catch (_e) {
|
||||
/* unique constraint already exists */
|
||||
}
|
||||
try {
|
||||
await knex.schema.alterTable("subscriber_methods", (table) => {
|
||||
table.foreign("subscriber_user_id").references("id").inTable("subscriber_users").onDelete("CASCADE");
|
||||
});
|
||||
} catch (_e) {
|
||||
/* foreign key already exists */
|
||||
}
|
||||
|
||||
// Verification code for email verification (6 digit)
|
||||
table.string("verification_code", 10).nullable();
|
||||
table.timestamp("verification_expires_at").nullable();
|
||||
// 3. Create user_subscriptions_v2 table
|
||||
if (!(await knex.schema.hasTable("user_subscriptions_v2"))) {
|
||||
await knex.schema.createTable("user_subscriptions_v2", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.integer("subscriber_user_id").unsigned().notNullable();
|
||||
table.integer("subscriber_method_id").unsigned().notNullable();
|
||||
table.string("event_type", 50).notNullable();
|
||||
table.string("status", 20).notNullable().defaultTo("ACTIVE");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
// Indexes
|
||||
table.index(["status"]);
|
||||
table.index(["email"]);
|
||||
});
|
||||
|
||||
// 2. Create subscriber_methods table - methods a user has configured
|
||||
await knex.schema.createTable("subscriber_methods", (table) => {
|
||||
table.increments("id").primary();
|
||||
|
||||
// Link to subscriber_user
|
||||
table.integer("subscriber_user_id").unsigned().notNullable();
|
||||
|
||||
// Method type: email, webhook, slack, discord
|
||||
table.string("method_type", 50).notNullable();
|
||||
|
||||
// Method value: email address, webhook URL, slack webhook, discord webhook
|
||||
table.string("method_value", 500).notNullable();
|
||||
|
||||
// Status: ACTIVE, INACTIVE
|
||||
table.string("status", 20).notNullable().defaultTo("ACTIVE");
|
||||
|
||||
// For webhook methods, we might want to store additional config
|
||||
table.text("meta").nullable(); // JSON for extra config like headers
|
||||
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
// Indexes
|
||||
table.index(["subscriber_user_id"]);
|
||||
table.index(["method_type"]);
|
||||
table.index(["status"]);
|
||||
|
||||
// Unique: one method type per value per user (can't have same webhook twice)
|
||||
table.unique(["subscriber_user_id", "method_type", "method_value"]);
|
||||
|
||||
// Foreign key
|
||||
table.foreign("subscriber_user_id").references("id").inTable("subscriber_users").onDelete("CASCADE");
|
||||
});
|
||||
|
||||
// 3. Create user_subscriptions_v2 table - what a user subscribes to
|
||||
await knex.schema.createTable("user_subscriptions_v2", (table) => {
|
||||
table.increments("id").primary();
|
||||
|
||||
// Link to subscriber_user
|
||||
table.integer("subscriber_user_id").unsigned().notNullable();
|
||||
|
||||
// Link to subscriber_method (which method to use for this subscription)
|
||||
table.integer("subscriber_method_id").unsigned().notNullable();
|
||||
|
||||
// What event type: incidents, maintenance
|
||||
table.string("event_type", 50).notNullable();
|
||||
|
||||
// Status: ACTIVE, INACTIVE
|
||||
table.string("status", 20).notNullable().defaultTo("ACTIVE");
|
||||
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
// Indexes
|
||||
table.index(["subscriber_user_id"]);
|
||||
table.index(["subscriber_method_id"]);
|
||||
table.index(["event_type"]);
|
||||
table.index(["status"]);
|
||||
|
||||
// Unique: one subscription per user-method-event-entity
|
||||
table.unique(["subscriber_user_id", "subscriber_method_id", "event_type"]);
|
||||
|
||||
// Foreign keys
|
||||
table.foreign("subscriber_user_id").references("id").inTable("subscriber_users").onDelete("CASCADE");
|
||||
table.foreign("subscriber_method_id").references("id").inTable("subscriber_methods").onDelete("CASCADE");
|
||||
});
|
||||
// Add indexes, unique constraints, and foreign keys for user_subscriptions_v2 (idempotent)
|
||||
try {
|
||||
await knex.schema.alterTable("user_subscriptions_v2", (table) => {
|
||||
table.index(["subscriber_user_id"]);
|
||||
});
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
try {
|
||||
await knex.schema.alterTable("user_subscriptions_v2", (table) => {
|
||||
table.index(["subscriber_method_id"]);
|
||||
});
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
try {
|
||||
await knex.schema.alterTable("user_subscriptions_v2", (table) => {
|
||||
table.index(["event_type"]);
|
||||
});
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
try {
|
||||
await knex.schema.alterTable("user_subscriptions_v2", (table) => {
|
||||
table.index(["status"]);
|
||||
});
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
try {
|
||||
await knex.schema.alterTable("user_subscriptions_v2", (table) => {
|
||||
table.unique(["subscriber_user_id", "subscriber_method_id", "event_type"], {
|
||||
indexName: "sub_v2_user_method_event_unique",
|
||||
});
|
||||
});
|
||||
} catch (_e) {
|
||||
/* unique constraint already exists */
|
||||
}
|
||||
try {
|
||||
await knex.schema.alterTable("user_subscriptions_v2", (table) => {
|
||||
table.foreign("subscriber_user_id").references("id").inTable("subscriber_users").onDelete("CASCADE");
|
||||
});
|
||||
} catch (_e) {
|
||||
/* foreign key already exists */
|
||||
}
|
||||
try {
|
||||
await knex.schema.alterTable("user_subscriptions_v2", (table) => {
|
||||
table.foreign("subscriber_method_id").references("id").inTable("subscriber_methods").onDelete("CASCADE");
|
||||
});
|
||||
} catch (_e) {
|
||||
/* foreign key already exists */
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasTable("general_email_templates")) return;
|
||||
|
||||
await knex.schema.createTable("general_email_templates", (table) => {
|
||||
table.string("template_id").primary();
|
||||
table.string("template_subject");
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable("monitors", (table) => {
|
||||
table.text("external_url").nullable();
|
||||
});
|
||||
await knex.schema.alterTable("monitoring_data", (table) => {
|
||||
table.text("error_message").nullable();
|
||||
});
|
||||
if (!(await knex.schema.hasColumn("monitors", "external_url"))) {
|
||||
await knex.schema.alterTable("monitors", (table) => {
|
||||
table.text("external_url").nullable();
|
||||
});
|
||||
}
|
||||
if (!(await knex.schema.hasColumn("monitoring_data", "error_message"))) {
|
||||
await knex.schema.alterTable("monitoring_data", (table) => {
|
||||
table.text("error_message").nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable("monitoring_data", (table) => {
|
||||
table.index(["timestamp"], "idx_monitoring_data_timestamp");
|
||||
table.index(["monitor_tag", "type", "timestamp"], "idx_monitoring_data_monitor_tag_type_timestamp");
|
||||
});
|
||||
try {
|
||||
await knex.schema.alterTable("monitoring_data", (table) => {
|
||||
table.index(["timestamp"], "idx_monitoring_data_timestamp");
|
||||
});
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
try {
|
||||
await knex.schema.alterTable("monitoring_data", (table) => {
|
||||
table.index(["monitor_tag", "type", "timestamp"], "idx_monitoring_data_monitor_tag_type_timestamp");
|
||||
});
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.table("incidents", (table) => {
|
||||
table.string("is_global", 15).notNullable().defaultTo("YES");
|
||||
});
|
||||
await knex.schema.table("maintenances", (table) => {
|
||||
table.string("is_global", 15).notNullable().defaultTo("YES");
|
||||
});
|
||||
if (!(await knex.schema.hasColumn("incidents", "is_global"))) {
|
||||
await knex.schema.table("incidents", (table) => {
|
||||
table.string("is_global", 15).notNullable().defaultTo("YES");
|
||||
});
|
||||
}
|
||||
if ((await knex.schema.hasTable("maintenances")) && !(await knex.schema.hasColumn("maintenances", "is_global"))) {
|
||||
await knex.schema.table("maintenances", (table) => {
|
||||
table.string("is_global", 15).notNullable().defaultTo("YES");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.table("incidents", (table) => {
|
||||
table.dropColumn("is_global");
|
||||
});
|
||||
await knex.schema.table("maintenances", (table) => {
|
||||
table.dropColumn("is_global");
|
||||
});
|
||||
if (await knex.schema.hasColumn("incidents", "is_global")) {
|
||||
await knex.schema.table("incidents", (table) => {
|
||||
table.dropColumn("is_global");
|
||||
});
|
||||
}
|
||||
if ((await knex.schema.hasTable("maintenances")) && (await knex.schema.hasColumn("maintenances", "is_global"))) {
|
||||
await knex.schema.table("maintenances", (table) => {
|
||||
table.dropColumn("is_global");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasCol = await knex.schema.hasColumn("users", "is_owner");
|
||||
if (!hasCol) {
|
||||
await knex.schema.alterTable("users", (table) => {
|
||||
table.string("is_owner").defaultTo("NO").notNullable();
|
||||
});
|
||||
|
||||
// Set the first user (by id) as owner, if any users exist.
|
||||
// This only runs when the column has just been added to avoid
|
||||
// overwriting an existing owner on migration re-run.
|
||||
const firstUser = await knex("users").orderBy("id", "asc").first();
|
||||
if (firstUser) {
|
||||
await knex("users").where("id", firstUser.id).update({ is_owner: "YES" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasCol = await knex.schema.hasColumn("users", "is_owner");
|
||||
if (hasCol) {
|
||||
await knex.schema.alterTable("users", (table) => {
|
||||
table.dropColumn("is_owner");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable("pages", (table) => {
|
||||
table.text("page_subheader").alter();
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable("pages", (table) => {
|
||||
table.string("page_subheader", 255).alter();
|
||||
});
|
||||
}
|
||||
@@ -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,16 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
// Closed default_status set as of docs/adr/0006. MAINTENANCE was offered by the old
|
||||
// UI but never honored by the fill engine — it behaved exactly like "no fill", so it
|
||||
// (and any other unknown value, and NULL) normalizes to NONE, preserving behavior.
|
||||
const VALID = ["NONE", "UP", "DOWN", "DEGRADED", "LAST_KNOWN"];
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex("monitors").whereNull("default_status").update({ default_status: "NONE" });
|
||||
await knex("monitors").whereNotIn("default_status", VALID).update({ default_status: "NONE" });
|
||||
}
|
||||
|
||||
export async function down(_knex: Knex): Promise<void> {
|
||||
// Irreversible by design: the values rewritten to NONE were dead (never honored
|
||||
// by the fill engine), so there is nothing meaningful to restore.
|
||||
}
|
||||
Generated
+405
-293
File diff suppressed because it is too large
Load Diff
+19
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kener",
|
||||
"version": "4.0.0",
|
||||
"version": "4.0.23",
|
||||
"type": "module",
|
||||
"private": false,
|
||||
"license": "MIT",
|
||||
@@ -40,10 +40,10 @@
|
||||
"devschedule": "vite-node src/lib/server/startup.ts",
|
||||
"generate-readme": "node scripts/generate-readme.js",
|
||||
"index-docs": "vite-node scripts/index-docs.ts",
|
||||
"migrate": "npx knex migrate:latest",
|
||||
"migrate": "vite-node scripts/fix-migration-ext.ts && npx knex migrate:latest",
|
||||
"predev": "npm run seed",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"preseed": "npx knex migrate:latest",
|
||||
"preseed": "vite-node scripts/fix-migration-ext.ts && npx knex migrate:latest",
|
||||
"prettify": "prettier --write .",
|
||||
"preview": "vite preview",
|
||||
"schedule": "vite-node src/lib/server/startup.ts",
|
||||
@@ -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,7 +64,7 @@
|
||||
"@types/d3-shape": "^3.1.8",
|
||||
"@types/dns2": "^2.0.10",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/heic-convert": "^2.1.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/mustache": "^4.2.6",
|
||||
"@types/node": "^25.0.3",
|
||||
@@ -80,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",
|
||||
@@ -113,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",
|
||||
@@ -136,9 +138,9 @@
|
||||
"figlet": "^1.9.4",
|
||||
"flexsearch": "^0.8.212",
|
||||
"front-matter": "^4.0.2",
|
||||
"fs-extra": "^11.3.2",
|
||||
"gamedig": "^5.3.2",
|
||||
"glob": "^13.0.6",
|
||||
"heic-convert": "^2.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ioredis": "^5.8.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
@@ -170,5 +172,15 @@
|
||||
"style-to-object": "^1.0.14",
|
||||
"svelte-codemirror-editor": "^2.1.0",
|
||||
"vite-plugin-package-version": "^1.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"fast-xml-parser": "^5.5.6",
|
||||
"rollup": "^4.59.0",
|
||||
"undici": "^7.24.0",
|
||||
"minimatch": "^10.2.3",
|
||||
"devalue": "^5.6.4",
|
||||
"dompurify": "^3.3.2",
|
||||
"cookie": "^0.7.0",
|
||||
"mailparser": "^3.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
+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;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Renames .js migration entries to .ts in the knex_migrations table.
|
||||
* This is needed because migration files were renamed from .js to .ts,
|
||||
* but existing databases still reference the old .js filenames.
|
||||
*
|
||||
* Idempotent — safe to run multiple times.
|
||||
*/
|
||||
import knex from "knex";
|
||||
import knexOb from "../knexfile.js";
|
||||
|
||||
const db = knex(knexOb);
|
||||
|
||||
async function fixMigrationExtensions() {
|
||||
try {
|
||||
const hasTable = await db.schema.hasTable("knex_migrations");
|
||||
if (!hasTable) {
|
||||
console.log("No knex_migrations table found, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
const oldJsMigrations = await db("knex_migrations").where("name", "like", "%.js");
|
||||
if (oldJsMigrations.length === 0) {
|
||||
console.log("No .js migration entries found, nothing to rename.");
|
||||
return;
|
||||
}
|
||||
|
||||
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: ${row.name} -> ${newName}`);
|
||||
}
|
||||
|
||||
console.log(`Fixed ${oldJsMigrations.length} migration record(s).`);
|
||||
} catch (err) {
|
||||
console.error("Error fixing migration extensions:", err);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
fixMigrationExtensions();
|
||||
@@ -47,7 +47,8 @@ interface DocsSidebarGroup {
|
||||
|
||||
interface DocsNavTab {
|
||||
name: string;
|
||||
sidebar: DocsSidebarGroup[];
|
||||
url?: string;
|
||||
sidebar?: DocsSidebarGroup[];
|
||||
}
|
||||
|
||||
interface DocsVersion {
|
||||
@@ -164,14 +165,16 @@ async function main(): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const primaryTabSidebar = latestVersion.content.navigation?.tabs?.[0]?.sidebar ?? [];
|
||||
const sidebar = normalizeSidebar(primaryTabSidebar);
|
||||
const tabs = latestVersion.content.navigation?.tabs ?? [];
|
||||
const documents: DocsSearchDocument[] = [];
|
||||
|
||||
// Collect all pages from sidebar
|
||||
// Collect all pages from all tabs' sidebars
|
||||
const allPages: Array<{ page: DocsPageSource; group: string }> = [];
|
||||
for (const sidebarGroup of sidebar) {
|
||||
collectPages(sidebarGroup.pages, sidebarGroup.group, allPages);
|
||||
for (const tab of tabs) {
|
||||
const sidebar = normalizeSidebar(tab.sidebar ?? []);
|
||||
for (const sidebarGroup of sidebar) {
|
||||
collectPages(sidebarGroup.pages, sidebarGroup.group, allPages);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[index-docs] Indexing version ${latestVersion.slug}`);
|
||||
|
||||
+122
-62
@@ -1,81 +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);
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
//migrations
|
||||
async function runMigrations() {
|
||||
try {
|
||||
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,274 @@
|
||||
/**
|
||||
* 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, 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",
|
||||
deleteMaintenanceEvent: "maintenances.write",
|
||||
addMonitorToMaintenance: "maintenances.write",
|
||||
removeMonitorFromMaintenance: "maintenances.write",
|
||||
updateMaintenanceMonitorImpact: "maintenances.write",
|
||||
|
||||
// Pages
|
||||
getPages: "pages.read",
|
||||
createPage: "pages.write",
|
||||
updatePage: "pages.write",
|
||||
deletePage: "pages.write",
|
||||
addMonitorToPage: "pages.write",
|
||||
removeMonitorFromPage: "pages.write",
|
||||
reorderPageMonitors: "pages.write",
|
||||
|
||||
// Triggers
|
||||
getTriggers: "triggers.read",
|
||||
createUpdateTrigger: "triggers.write",
|
||||
updateMonitorTriggers: "triggers.write",
|
||||
deleteTrigger: "triggers.write",
|
||||
testTrigger: "triggers.write",
|
||||
|
||||
// Alerts
|
||||
getAllAlertsPaginated: "alerts.read",
|
||||
getMonitorAlertConfig: "alerts.read",
|
||||
getMonitorAlertConfigById: "alerts.read",
|
||||
getMonitorAlertConfigsByMonitorTag: "alerts.read",
|
||||
getAlertConfigsPaginated: "alerts.read",
|
||||
createMonitorAlertConfig: "alerts.write",
|
||||
updateMonitorAlertConfig: "alerts.write",
|
||||
deleteMonitorAlertConfig: "alerts.write",
|
||||
toggleMonitorAlertConfigStatus: "alerts.write",
|
||||
deleteMonitorAlertV2: "alerts.write",
|
||||
updateMonitorAlertV2Status: "alerts.write",
|
||||
|
||||
// API Keys
|
||||
getAPIKeys: "api_keys.read",
|
||||
createNewApiKey: "api_keys.write",
|
||||
updateApiKeyStatus: "api_keys.write",
|
||||
deleteApiKey: "api_keys.delete",
|
||||
|
||||
// Subscribers
|
||||
getSubscribersByMethod: "subscribers.read",
|
||||
getSubscriberWithSubscriptions: "subscribers.read",
|
||||
getSubscriberCountsByMethod: "subscribers.read",
|
||||
getAdminSubscribers: "subscribers.read",
|
||||
deleteUserSubscription: "subscribers.write",
|
||||
updateUserSubscriptionStatus: "subscribers.write",
|
||||
adminUpdateSubscriptionStatus: "subscribers.write",
|
||||
adminDeleteSubscriber: "subscribers.write",
|
||||
adminAddSubscriber: "subscribers.write",
|
||||
|
||||
// Email Templates
|
||||
getGeneralEmailTemplates: "email_templates.read",
|
||||
getGeneralEmailTemplateById: "email_templates.read",
|
||||
updateGeneralEmailTemplate: "email_templates.write",
|
||||
|
||||
// Images
|
||||
uploadImage: "images.write",
|
||||
deleteImage: "images.write",
|
||||
|
||||
// Roles
|
||||
getRoles: "roles.read",
|
||||
getAllPermissions: "roles.read",
|
||||
getRolePermissions: "roles.read",
|
||||
getRoleUsers: "roles.read",
|
||||
createRole: "roles.write",
|
||||
updateRole: "roles.write",
|
||||
deleteRole: "roles.write",
|
||||
updateRolePermissions: "roles.assign_permissions",
|
||||
addUserToRole: "roles.assign_users",
|
||||
removeUserFromRole: "roles.assign_users",
|
||||
};
|
||||
|
||||
export const ROUTE_PERMISSION_MAP: Record<string, string | null> = {
|
||||
// Monitors
|
||||
"/(manage)/manage/app/monitors": "monitors.read",
|
||||
"/(manage)/manage/app/monitors/[tag]": "monitors.read",
|
||||
"/(manage)/manage/app/monitoring-data": "monitors.read",
|
||||
|
||||
// Incidents
|
||||
"/(manage)/manage/app/incidents": "incidents.read",
|
||||
"/(manage)/manage/app/incidents/[incident_id]": "incidents.read",
|
||||
|
||||
// Maintenances
|
||||
"/(manage)/manage/app/maintenances": "maintenances.read",
|
||||
"/(manage)/manage/app/maintenances/[id]": "maintenances.read",
|
||||
|
||||
// Pages
|
||||
"/(manage)/manage/app/pages": "pages.read",
|
||||
"/(manage)/manage/app/pages/[page_id]": "pages.read",
|
||||
|
||||
// Triggers
|
||||
"/(manage)/manage/app/triggers": "triggers.read",
|
||||
"/(manage)/manage/app/triggers/[trigger_id]": "triggers.read",
|
||||
|
||||
// Alerts
|
||||
"/(manage)/manage/app/alerts": "alerts.read",
|
||||
"/(manage)/manage/app/alerts/[alert_config_id]": "alerts.read",
|
||||
"/(manage)/manage/app/alerts/logs/[alert_config_id]": "alerts.read",
|
||||
|
||||
// API Keys
|
||||
"/(manage)/manage/app/api-keys": "api_keys.read",
|
||||
|
||||
// Users
|
||||
"/(manage)/manage/app/users": "users.read",
|
||||
|
||||
// Settings
|
||||
"/(manage)/manage/app/site-configurations": "settings.read",
|
||||
"/(manage)/manage/app/customizations": "settings.read",
|
||||
"/(manage)/manage/app/internationalization": "settings.read",
|
||||
"/(manage)/manage/app/analytics-providers": "settings.read",
|
||||
"/(manage)/manage/app/badges": "settings.read",
|
||||
"/(manage)/manage/app/embed": "settings.read",
|
||||
|
||||
// Subscribers
|
||||
"/(manage)/manage/app/subscriptions": "subscribers.read",
|
||||
|
||||
// Email Templates
|
||||
"/(manage)/manage/app/templates": "email_templates.read",
|
||||
|
||||
// Roles
|
||||
"/(manage)/manage/app/roles": "roles.read",
|
||||
};
|
||||
@@ -31,3 +31,39 @@ export default function urlResolve(resolve: ResolveFn, path: string, params?: Re
|
||||
}
|
||||
return resolve(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a path to an absolute URL by prefixing the site URL.
|
||||
* Required for meta tags like og:image and twitter:image that need absolute URLs.
|
||||
* @param resolve - The resolve function from $app/paths
|
||||
* @param siteUrl - The site URL (e.g., "https://status.example.com")
|
||||
* @param path - The route path or absolute URL
|
||||
* @param params - Optional parameters for dynamic route segments
|
||||
* @returns An absolute URL, or the resolved relative URL if siteUrl is empty
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* absoluteResolve(resolve, "https://status.example.com", "/uploads/preview.png")
|
||||
* // => "https://status.example.com/uploads/preview.png"
|
||||
* ```
|
||||
*/
|
||||
export function absoluteResolve(
|
||||
resolve: ResolveFn,
|
||||
siteUrl: string,
|
||||
path: string,
|
||||
params?: Record<string, string>
|
||||
): string {
|
||||
// Normalize relative paths like "./assets/..." to "/assets/..." so the
|
||||
// final URL doesn't contain "/./" segments (crawlers don't normalize these)
|
||||
const normalizedPath = path.startsWith("./") ? path.slice(1) : path;
|
||||
const resolved = urlResolve(resolve, normalizedPath, params);
|
||||
// Already absolute, return as-is
|
||||
if (resolved.startsWith("http://") || resolved.startsWith("https://")) {
|
||||
return resolved;
|
||||
}
|
||||
if (!siteUrl) {
|
||||
return resolved;
|
||||
}
|
||||
const trimmedSiteUrl = siteUrl.replace(/\/+$/, "");
|
||||
return trimmedSiteUrl + (resolved.startsWith("/") ? resolved : "/" + resolved);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<Item.Root class="items-start p-0 {className} sm:items-center">
|
||||
<Item.Content class="min-w-0 flex-1">
|
||||
<div class="flex flex-col items-start justify-start gap-0.5">
|
||||
<span class="text-xs font-medium text-{incident.state.toLowerCase()}">{incident.state}</span>
|
||||
<span class="text-xs font-medium text-{incident.state.toLowerCase()}">{$t(incident.state)}</span>
|
||||
<Item.Title class="min-w-0 text-base wrap-break-word break-all">
|
||||
<a {target} class="hover:underline" href={clientResolver(resolve, `/incidents/${incident.id}`)}>
|
||||
{incident.title}
|
||||
@@ -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">
|
||||
@@ -128,14 +128,14 @@
|
||||
{#if showSummary}
|
||||
<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>Last Updated</span>
|
||||
<span>{$formatDate(incident.updated_at, "PPp")}</span>
|
||||
<span>{$t("Last Updated")}</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>Status</span>
|
||||
<span>{$t("Status")}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-{incident.state.toLowerCase()}">
|
||||
{incident.state}
|
||||
{$t(incident.state)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,15 @@
|
||||
|
||||
let { data } = page;
|
||||
const navItems: { name: string; url: string; iconURL: string }[] = data.navItems || [];
|
||||
const { siteName, siteUrl, logo } = data;
|
||||
const { siteName, logo, globalPageVisibilitySettings } = data;
|
||||
|
||||
const brandPath = $derived.by(() => {
|
||||
if (globalPageVisibilitySettings?.forceExclusivity) {
|
||||
const currentPagePath = page.params?.page_path?.trim();
|
||||
return currentPagePath ? `/${currentPagePath}` : "/";
|
||||
}
|
||||
return "/";
|
||||
});
|
||||
|
||||
function trackBrandClick() {
|
||||
trackEvent("nav_brand_clicked", { name: siteName });
|
||||
@@ -29,7 +37,7 @@
|
||||
>
|
||||
<!-- Brand -->
|
||||
<a
|
||||
href={clientResolver(resolve, siteUrl)}
|
||||
href={clientResolver(resolve, brandPath)}
|
||||
class="{navigationMenuTriggerStyle()} hover:border-border border border-transparent bg-transparent text-xs hover:bg-transparent"
|
||||
style="border-radius: var(--radius-3xl)"
|
||||
onclick={trackBrandClick}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<Item.Root class="items-start p-0 {className} sm:items-center">
|
||||
<Item.Content class="min-w-0 flex-1">
|
||||
<div class="flex flex-col items-start justify-start gap-0.5">
|
||||
<span class="text-xs font-medium text-{maintenance.status.toLowerCase()}">{maintenance.status}</span>
|
||||
<span class="text-xs font-medium text-{maintenance.status.toLowerCase()}">{$t(maintenance.status)}</span>
|
||||
<Item.Title class="min-w-0 text-base wrap-break-word break-all">
|
||||
<a {target} class="hover:underline" href={clientResolver(resolve, `/maintenances/${maintenance.id}`)}
|
||||
>{maintenance.title}</a
|
||||
@@ -97,7 +97,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 +108,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>
|
||||
@@ -1,14 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from "$app/paths";
|
||||
import { page } from "$app/state";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import * as Popover from "$lib/components/ui/popover/index.js";
|
||||
import { Spinner } from "$lib/components/ui/spinner/index.js";
|
||||
import ICONS from "$lib/icons";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import { formatDate } from "$lib/stores/datetime";
|
||||
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 { onMount } from "svelte";
|
||||
|
||||
interface Props {
|
||||
@@ -22,6 +24,17 @@
|
||||
let notifications = $state<NotificationEvent[]>([]);
|
||||
let loading = $state(false);
|
||||
|
||||
const defaultEventsPath = $derived(`/events/${format(new Date(), "MMMM-yyyy")}`);
|
||||
|
||||
const resolvedEventsPath = $derived.by(() => {
|
||||
const finalEventsPath = eventsPath || defaultEventsPath;
|
||||
if (page.data?.globalPageVisibilitySettings?.forceExclusivity) {
|
||||
const currentPagePath = page.params?.page_path?.trim();
|
||||
return currentPagePath ? `/${currentPagePath}${finalEventsPath}` : finalEventsPath;
|
||||
}
|
||||
return finalEventsPath;
|
||||
});
|
||||
|
||||
async function fetchNotifications() {
|
||||
loading = true;
|
||||
try {
|
||||
@@ -76,7 +89,7 @@
|
||||
>
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h4 class="text-sm font-semibold">{$t("Events")}</h4>
|
||||
<Button variant="outline" href={clientResolver(resolve, eventsPath)} size="icon-sm" class="rounded-btn">
|
||||
<Button variant="outline" href={clientResolver(resolve, resolvedEventsPath)} size="icon-sm" class="rounded-btn">
|
||||
<Calendar class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -93,17 +106,17 @@
|
||||
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">{item.eventType}</span>
|
||||
<span class="text-{item.eventStatus.toLowerCase()}">{item.eventStatus}</span>
|
||||
<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>{$formatDate(item.eventDate, page.data.dateAndTimeFormat.datePlusTime)}</span>
|
||||
|
||||
<span>•</span>
|
||||
<span>{item.eventDuration}</span>
|
||||
<span>{$formatDuration(item.eventStartDateTime, item.eventEndDateTime, $t("Ongoing"))}</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
|
||||
import { Spinner } from "$lib/components/ui/spinner/index.js";
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import { onMount } from "svelte";
|
||||
import type { PageNavItem } from "$lib/server/controllers/dashboardController.js";
|
||||
import { page } from "$app/state";
|
||||
import * as Item from "$lib/components/ui/item/index.js";
|
||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||
|
||||
let currentPath = $derived(page.params.page_path);
|
||||
|
||||
let pages = $state<PageNavItem[]>([]);
|
||||
let pagesLoading = $state(false);
|
||||
const defaultHomePage = $derived(pages.find((p) => p.page_path == ""));
|
||||
const currentPage = $derived(pages.find((p) => p.page_path === currentPath) || defaultHomePage);
|
||||
|
||||
async function fetchPages() {
|
||||
pagesLoading = true;
|
||||
try {
|
||||
const response = await fetch(clientResolver(resolve, "/dashboard-apis/pages"));
|
||||
if (response.ok) {
|
||||
pages = await response.json();
|
||||
console.log(">>>>>>---- PageList:25 ", pages);
|
||||
}
|
||||
} catch {
|
||||
// silently fail, pages dropdown will just not show
|
||||
} finally {
|
||||
pagesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchPages();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
{#if pagesLoading}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="bg-background/80 dark:bg-background/70 border-foreground/10 flex items-center justify-center rounded-full border text-xs shadow-none backdrop-blur-md"
|
||||
disabled
|
||||
>
|
||||
<Spinner class="h-4 w-4" />
|
||||
</Button>
|
||||
{:else if pages.length > 0}
|
||||
<!-- loop through pages -->
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
{#each pages as page}
|
||||
<Item.Root variant="outline" class="rounded-3xl">
|
||||
{#snippet child({ props })}
|
||||
<a href={clientResolver(resolve, `/${page.page_path}`)} {...props}>
|
||||
{#if page.page_logo}
|
||||
<Item.Media variant="image">
|
||||
<img
|
||||
src={page.page_logo}
|
||||
alt={page.page_title}
|
||||
width="32"
|
||||
height="32"
|
||||
class="size-8 rounded object-cover"
|
||||
/>
|
||||
</Item.Media>
|
||||
{/if}
|
||||
<Item.Content>
|
||||
<Item.Title class="line-clamp-1">
|
||||
{page.page_title}
|
||||
</Item.Title>
|
||||
<Item.Description>{page.page_header}</Item.Description>
|
||||
</Item.Content>
|
||||
<Item.Actions>
|
||||
<ChevronRight class="size-4" />
|
||||
</Item.Actions>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Item.Root>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -13,8 +13,8 @@
|
||||
|
||||
let pages = $state<PageNavItem[]>([]);
|
||||
let pagesLoading = $state(false);
|
||||
|
||||
const currentPage = $derived(pages.find((p) => p.page_path === currentPath) || pages[0]);
|
||||
const defaultHomePage = $derived(pages.find((p) => p.page_path == ""));
|
||||
const currentPage = $derived(pages.find((p) => p.page_path === currentPath) || defaultHomePage);
|
||||
|
||||
async function fetchPages() {
|
||||
pagesLoading = true;
|
||||
|
||||
@@ -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,7 +10,6 @@
|
||||
import Sun from "@lucide/svelte/icons/sun";
|
||||
import Moon from "@lucide/svelte/icons/moon";
|
||||
import Share from "@lucide/svelte/icons/share-2";
|
||||
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
|
||||
import { format } from "date-fns";
|
||||
import SubscribeMenu from "$lib/components/SubscribeMenu.svelte";
|
||||
import CopyButton from "$lib/components/CopyButton.svelte";
|
||||
@@ -41,22 +40,22 @@
|
||||
|
||||
if (page.route.id === "/(kener)/monitors/[monitor_tag]") {
|
||||
return {
|
||||
label: "Edit Monitor",
|
||||
label: $t("Edit Monitor"),
|
||||
url: clientResolver(resolve, "/manage/app/monitors/" + page.params.monitor_tag)
|
||||
};
|
||||
} else if (page.route.id === "/(kener)/incidents/[incident_id]") {
|
||||
return {
|
||||
label: "Update Incident",
|
||||
label: $t("Update Incident"),
|
||||
url: clientResolver(resolve, "/manage/app/incidents/" + page.params.incident_id)
|
||||
};
|
||||
} else if (page.route.id === "/(kener)/maintenances/[maintenance_id]") {
|
||||
return {
|
||||
label: "Update Maintenance",
|
||||
label: $t("Update Maintenance"),
|
||||
url: clientResolver(resolve, "/manage/app/maintenances/" + page.data.maintenance.id)
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
label: "Manage Site",
|
||||
label: $t("Manage Site"),
|
||||
url: clientResolver(resolve, "/manage/app/site-configurations")
|
||||
};
|
||||
}
|
||||
@@ -79,7 +78,10 @@
|
||||
</script>
|
||||
|
||||
<div class="theme-plus-bar scrollbar-hidden sticky top-18 z-20 flex w-full items-center gap-2 rounded py-2">
|
||||
<PageSelector />
|
||||
<!-- Show the switcher if not forced exclusivity and switcher is enabled -->
|
||||
{#if !!!page.data.globalPageVisibilitySettings.forceExclusivity && page.data.globalPageVisibilitySettings.showSwitcher}
|
||||
<PageSelector />
|
||||
{/if}
|
||||
<div class="ml-auto flex shrink-0 items-center gap-2">
|
||||
{#if page.data.isSubsEnabled && page.data.canSendEmail}
|
||||
<ButtonGroup.Root class="hidden shrink-0 sm:flex">
|
||||
@@ -168,6 +170,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !!page.data.announcement && !!page.data.announcement.title && !!page.data.announcement.message}
|
||||
<SiteBanner announcement={page.data.announcement} />
|
||||
{/if}
|
||||
|
||||
@@ -41,6 +41,8 @@ export default {
|
||||
MANUAL: "MANUAL",
|
||||
WEBHOOK: "WEBHOOK",
|
||||
DEFAULT_STATUS: "DEFAULT",
|
||||
CARRIED: "CARRIED",
|
||||
LAST_KNOWN: "LAST_KNOWN",
|
||||
SIGNAL: "SIGNAL",
|
||||
INVITE_VERIFY_EMAIL: "invite_verify_email",
|
||||
ERROR_NO_SETUP: "Set up not done yet. Create a user first.",
|
||||
@@ -69,6 +71,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,
|
||||
|
||||
+79
-37
@@ -2,114 +2,156 @@
|
||||
"name": "Čeština",
|
||||
"mappings": {
|
||||
"%latency %metric latency": "%latency %metric latence",
|
||||
"24 Hours": "24 hodin",
|
||||
"30 Days": "30 dní",
|
||||
"7 Days": "7 dní",
|
||||
"90 Days": "90 dní",
|
||||
"Affected Monitors (%count)": "Zasažené monitory (%count)",
|
||||
"All Systems Operational": "Všechny systémy jsou v provozu",
|
||||
"average": "průměrná",
|
||||
"Average Latency": "Průměrná latence",
|
||||
"Avg Latency": "Prům. latence",
|
||||
"Back": "Zpět",
|
||||
"Badges": "Odznaky",
|
||||
"CANCELLED": "ZRUŠENO",
|
||||
"COMPLETED": "DOKONČENO",
|
||||
"Continue": "Pokračovat",
|
||||
"Copied": "Zkopírováno",
|
||||
"Current": "Aktuální",
|
||||
"Dark": "Tmavý",
|
||||
"Day": "Den",
|
||||
"Day Uptime": "Denní dostupnost",
|
||||
"Days": "Dny",
|
||||
"Degraded Performance": "Zhoršený výkon",
|
||||
"Didn't receive the code? Resend": "Nepřišel vám kód? Odeslat znovu",
|
||||
"Days": "Dní",
|
||||
"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ánované údržby.",
|
||||
"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": "Included Monitors",
|
||||
"Included Monitors (%count)": "Zahrnuté monitory (%count)",
|
||||
"INVESTIGATING": "VYŠETŘOVÁNÍ",
|
||||
"Last Updated": "Naposledy aktualizováno",
|
||||
"Latency": "Latence",
|
||||
"Latency Embed": "Vložená latence",
|
||||
"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": "Živý stav",
|
||||
"Loading your preferences...": "Načítám vaše nastavení...",
|
||||
"Live Status": "Aktuální stav",
|
||||
"Loading your preferences...": "Načítání nastavení...",
|
||||
"maintenance": "údržba",
|
||||
"Maintenance": "Údržba",
|
||||
"MAINTENANCE": "ÚDRŽBA",
|
||||
"Maintenance Updates": "Aktualizace údržby",
|
||||
"Maintenances": "Údržby",
|
||||
"Major System Outage": "Závažný výpadek systému",
|
||||
"Manage your notification preferences.": "Spravujte svá nastavení oznámení.",
|
||||
"Manage Site": "Spravovat stránku",
|
||||
"Manage your notification preferences.": "Spravujte nastavení oznámení",
|
||||
"Max Latency": "Max. latence",
|
||||
"Maximum Latency": "Max. latence",
|
||||
"maximum": "maximální",
|
||||
"Maximum Latency": "Maximální latence",
|
||||
"Min Latency": "Min. latence",
|
||||
"Minimum Latency": "Min. latence",
|
||||
"minimum": "minimální",
|
||||
"Minimum Latency": "Minimální latence",
|
||||
"Minute-by-minute status data for this day": "Minutová data stavu pro tento den",
|
||||
"Network error. Please try again.": "Chyba sítě. Zkuste to prosím znovu.",
|
||||
"No Events in %currentMonth": "V měsíci %currentMonth nejsou žádné události",
|
||||
"No events to show": "No events to show",
|
||||
"No incidents for this day": "Pro tento den nejsou žádné incidenty",
|
||||
"No latency data available for this day": "Pro tento den nejsou k dispozici data latence",
|
||||
"No maintenances for this day": "Pro tento den nejsou naplánované žádné údržby",
|
||||
"MONITORING": "MONITOROVÁNÍ",
|
||||
"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 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.": "No monitors available.",
|
||||
"No ongoing maintenances": "Žádné probíhající údržby",
|
||||
"No past maintenances": "Žádné minulé údržby",
|
||||
"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žby",
|
||||
"No Updates": "No Updates",
|
||||
"No updates yet": "Zatím bez aktualizací",
|
||||
"Notifications": "Notifications",
|
||||
"No upcoming maintenances": "Žádná nadcházející údržba",
|
||||
"No Updates": "Žádné aktualizace",
|
||||
"No updates yet": "Zatím žádné aktualizace",
|
||||
"NO_DATA": "Žádná data",
|
||||
"Notifications": "Oznámení",
|
||||
"One-time": "Jednorázově",
|
||||
"Ongoing": "Probíhající",
|
||||
"Partial Degraded Performance": "Částečně zhoršený výkon",
|
||||
"ONGOING": "PROBÍHAJÍCÍ",
|
||||
"Ongoing Maintenances": "Probíhající údržby",
|
||||
"Operational": "V provozu",
|
||||
"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",
|
||||
"Recurring": "Recurring",
|
||||
"READY": "PŘIPRAVENO",
|
||||
"Recent Incidents": "Nedávné incidenty",
|
||||
"Recurring": "Opakující se",
|
||||
"RESOLVED": "VYŘEŠENO",
|
||||
"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": "Select latency metric to display",
|
||||
"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": "Vložený stav",
|
||||
"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",
|
||||
"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.": "There are no ongoing incidents or maintenance events.",
|
||||
"Theme": "Motiv",
|
||||
"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",
|
||||
"Under Maintenance": "Probíhá údržba",
|
||||
"Unknown impact": "Neznámý dopad",
|
||||
"UP": "V PROVOZU",
|
||||
"Upcoming": "Nadcházející",
|
||||
"Updates": "Updates",
|
||||
"Upcoming Maintenances": "Nadcházející údržby",
|
||||
"Update Incident": "Aktualizovat incident",
|
||||
"Update Maintenance": "Aktualizovat údržbu",
|
||||
"Updates": "Aktualizace",
|
||||
"Updates (%count)": "Aktualizace (%count)",
|
||||
"Uptime": "Dostupnost",
|
||||
"Uptime Badge": "Odznak dostupnosti",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
+52
-30
@@ -5,38 +5,48 @@
|
||||
"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",
|
||||
"Continue": "Weitermachen",
|
||||
"Badges": "Anzeigen",
|
||||
"CANCELLED": "ABGESAGT",
|
||||
"COMPLETED": "ABGESCHLOSSEN",
|
||||
"Continue": "Fortsetzen",
|
||||
"Copied": "Kopiert",
|
||||
"Current": "Aktuell",
|
||||
"Dark": "Dunkel",
|
||||
"Day": "Tag",
|
||||
"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": "IDENTIFIZIERT",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Auswirkungen",
|
||||
"Incident Updates": "Vorfallaktualisierungen",
|
||||
"incident": "Vorfall",
|
||||
"Incident Updates": "Vorfallsaktualisierungen",
|
||||
"Incidents": "Vorfälle",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Enthaltene Monitore (%count)",
|
||||
"INVESTIGATING": "WIRD UNTERSUCHT",
|
||||
"Last Updated": "Zuletzt aktualisiert",
|
||||
"Latency": "Latenz",
|
||||
"Latency Embed": "Latenz-Einbettung",
|
||||
@@ -44,35 +54,40 @@
|
||||
"Latency Trend": "Latenztrend",
|
||||
"Latest Latency": "Neueste Latenz",
|
||||
"Latest Status": "Neuester Stand",
|
||||
"Light": "Licht",
|
||||
"Light": "Hell",
|
||||
"Live Status": "Live-Status",
|
||||
"Loading your preferences...": "Deine Einstellungen werden geladen...",
|
||||
"maintenance": "Wartung",
|
||||
"MAINTENANCE": "WARTUNG",
|
||||
"Maintenance Updates": "Wartungsaktualisierungen",
|
||||
"Maintenances": "Wartungen",
|
||||
"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",
|
||||
"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": "No events to show",
|
||||
"No events to show": "Keine Ereignisse zum Anzeigen",
|
||||
"No incidents for this day": "Keine Vorfälle für diesen Tag",
|
||||
"No latency data available for this day": "Für diesen Tag sind keine Latenzdaten verfügbar",
|
||||
"No maintenances for this day": "An diesem Tag finden keine Wartungsarbeiten statt",
|
||||
"No monitors affected": "Keine Monitore betroffen",
|
||||
"No monitors available.": "No monitors available.",
|
||||
"No monitors available.": "Keine Monitore verfügbar.",
|
||||
"No ongoing maintenances": "Keine laufenden Wartungsarbeiten",
|
||||
"No past maintenances": "Keine früheren Wartungsarbeiten",
|
||||
"No Status Available": "Kein Status verfügbar",
|
||||
"No upcoming maintenances": "Keine bevorstehenden Wartungsarbeiten",
|
||||
"No Updates": "No Updates",
|
||||
"No updates yet": "Noch keine Updates",
|
||||
"Notifications": "Notifications",
|
||||
"No Updates": "Keine Aktualisierungen",
|
||||
"No updates yet": "Noch keine Aktualisierungen",
|
||||
"Notifications": "Benachrichtigungen",
|
||||
"One-time": "Einmalig",
|
||||
"Ongoing": "Laufend",
|
||||
"Operational": "Betriebsbereit",
|
||||
"Partial Degraded Performance": "Teilweise beeinträchtigte Leistung",
|
||||
"Partial System Outage": "Teilweiser Systemausfall",
|
||||
"Past": "Vergangenheit",
|
||||
@@ -82,34 +97,41 @@
|
||||
"Please enter the 6-digit verification code": "Bitte geben Sie den 6-stelligen Bestätigungscode ein",
|
||||
"Read less": "Weniger lesen",
|
||||
"Read more": "Mehr lesen",
|
||||
"Recurring": "Recurring",
|
||||
"READY": "BEREIT",
|
||||
"Recurring": "Wiederkehrend",
|
||||
"RESOLVED": "BEHOBEN",
|
||||
"SCHEDULED": "GEPLANT",
|
||||
"Scheduled Events (%count)": "Geplante Ereignisse (%count)",
|
||||
"Script": "Skript",
|
||||
"Select Language": "Wählen Sie Sprache aus",
|
||||
"Select latency metric to display": "Select latency metric to display",
|
||||
"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": "Abonnieren Sie Updates",
|
||||
"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.": "There are no ongoing incidents or maintenance events.",
|
||||
"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",
|
||||
"Updates": "Updates",
|
||||
"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"
|
||||
"We sent a 6-digit code to": "Wir haben einen 6-stelligen Code gesendet an"
|
||||
}
|
||||
}
|
||||
|
||||
+31
-9
@@ -8,14 +8,21 @@
|
||||
"Avg Latency": "Gns. latens",
|
||||
"Back": "Tilbage",
|
||||
"Badges": "Badges",
|
||||
"CANCELLED": "ANNULLERET",
|
||||
"COMPLETED": "AFSLUTTET",
|
||||
"Continue": "Fortsætte",
|
||||
"Copied": "Kopieret",
|
||||
"Current": "Aktuel",
|
||||
"Dark": "Mørk",
|
||||
"Day": "Dag",
|
||||
"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",
|
||||
@@ -32,11 +39,14 @@
|
||||
"Get notified about incidents and scheduled maintenance.": "Få besked om hændelser og planlagt vedligeholdelse.",
|
||||
"Get notified about incidents updates": "Få besked om opdateringer om hændelser",
|
||||
"Get notified about scheduled maintenance": "Få besked om planlagt vedligeholdelse",
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Indvirkning",
|
||||
"incident": "Hændelse",
|
||||
"Incident Updates": "Hændelsesopdateringer",
|
||||
"Incidents": "Hændelser",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Sidst opdateret",
|
||||
"Latency": "Latency",
|
||||
"Latency Embed": "Latency Embed",
|
||||
@@ -47,32 +57,37 @@
|
||||
"Light": "Lys",
|
||||
"Live Status": "Live status",
|
||||
"Loading your preferences...": "Indlæser dine præferencer...",
|
||||
"maintenance": "Vedligeholdelse",
|
||||
"MAINTENANCE": "VEDLIGEHOLDELSE",
|
||||
"Maintenance Updates": "Vedligeholdelsesopdateringer",
|
||||
"Maintenances": "Vedligeholdelse",
|
||||
"Major System Outage": "Større systemnedbrud",
|
||||
"Manage Site": "Administrer side",
|
||||
"Manage your notification preferences.": "Administrer dine meddelelsespræferencer.",
|
||||
"Max Latency": "Max latens",
|
||||
"Maximum Latency": "Max latens",
|
||||
"Min Latency": "Min latens",
|
||||
"Minimum Latency": "Min latens",
|
||||
"Minute-by-minute status data for this day": "Minut for minut statusdata for denne dag",
|
||||
"MONITORING": "MONITORING",
|
||||
"Network error. Please try again.": "Netværksfejl. ",
|
||||
"No Events in %currentMonth": "Ingen begivenheder i %currentMonth",
|
||||
"No events to show": "No events to show",
|
||||
"No events to show": "Ingen hændelser at vise",
|
||||
"No incidents for this day": "Ingen hændelser denne dag",
|
||||
"No latency data available for this day": "Ingen forsinkelsesdata tilgængelige for denne dag",
|
||||
"No maintenances for this day": "Ingen vedligeholdelse denne dag",
|
||||
"No monitors affected": "Ingen skærme påvirket",
|
||||
"No monitors available.": "No monitors available.",
|
||||
"No monitors available.": "Ingen monitorer tilgængelige.",
|
||||
"No ongoing maintenances": "Ingen løbende vedligeholdelse",
|
||||
"No past maintenances": "Ingen tidligere vedligeholdelse",
|
||||
"No Status Available": "Ingen status tilgængelig",
|
||||
"No upcoming maintenances": "Ingen kommende vedligeholdelse",
|
||||
"No Updates": "No Updates",
|
||||
"No Updates": "Ingen opdateringer",
|
||||
"No updates yet": "Ingen opdateringer endnu",
|
||||
"Notifications": "Notifications",
|
||||
"Notifications": "Notifikationer",
|
||||
"One-time": "Engangs",
|
||||
"Ongoing": "Løbende",
|
||||
"Operational": "Operationel",
|
||||
"Partial Degraded Performance": "Delvist nedsat ydeevne",
|
||||
"Partial System Outage": "Delvist systemnedbrud",
|
||||
"Past": "Forbi",
|
||||
@@ -82,10 +97,14 @@
|
||||
"Please enter the 6-digit verification code": "Indtast venligst den 6-cifrede bekræftelseskode",
|
||||
"Read less": "Læs mindre",
|
||||
"Read more": "Læs mere",
|
||||
"Recurring": "Recurring",
|
||||
"READY": "KLAR",
|
||||
"Recurring": "Tilbagevendende",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"SCHEDULED": "PLANLAGT",
|
||||
"Scheduled Events (%count)": "Planlagte begivenheder (%count)",
|
||||
"Script": "Manuskript",
|
||||
"Select Language": "Vælg sprog",
|
||||
"Select latency metric to display": "Select latency metric to display",
|
||||
"Select latency metric to display": "Vælg latenstidsmåling, der skal vises",
|
||||
"Select Range": "Vælg Område",
|
||||
"Sending...": "Sender...",
|
||||
"Standard": "Standard",
|
||||
@@ -97,13 +116,16 @@
|
||||
"Subscribe": "Abonner",
|
||||
"Subscribe to Updates": "Abonner på opdateringer",
|
||||
"There are no incidents or maintenances scheduled for this month.": "Der er ingen hændelser eller vedligeholdelse planlagt i denne måned.",
|
||||
"There are no ongoing incidents or maintenance events.": "There are no ongoing incidents or maintenance events.",
|
||||
"There are no ongoing incidents or maintenance events.": "Der er ingen igangværende hændelser eller vedligeholdelsesaktiviteter.",
|
||||
"Total Incidents": "Samlede hændelser",
|
||||
"Total Maintenances": "Samlet vedligeholdelse",
|
||||
"Under Maintenance": "Under Vedligeholdelse",
|
||||
"Unknown impact": "Ukendt påvirkning",
|
||||
"UP": "OPPE",
|
||||
"Upcoming": "Kommende",
|
||||
"Updates": "Updates",
|
||||
"Update Incident": "Opdater hændelse",
|
||||
"Update Maintenance": "Opdater vedligeholdelse",
|
||||
"Updates": "Opdateringer",
|
||||
"Updates (%count)": "Opdateringer (%count)",
|
||||
"Uptime": "Oppetid",
|
||||
"Uptime Badge": "Oppetidsmærke",
|
||||
|
||||
+23
-1
@@ -8,14 +8,21 @@
|
||||
"Avg Latency": "Avg Latency",
|
||||
"Back": "Back",
|
||||
"Badges": "Badges",
|
||||
"CANCELLED": "CANCELLED",
|
||||
"COMPLETED": "COMPLETED",
|
||||
"Continue": "Continue",
|
||||
"Copied": "Copied",
|
||||
"Current": "Current",
|
||||
"Dark": "Dark",
|
||||
"Day": "Day",
|
||||
"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",
|
||||
@@ -32,11 +39,14 @@
|
||||
"Get notified about incidents and scheduled maintenance.": "Get notified about incidents and scheduled maintenance.",
|
||||
"Get notified about incidents updates": "Get notified about incidents updates",
|
||||
"Get notified about scheduled maintenance": "Get notified about scheduled maintenance",
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Impact",
|
||||
"incident": "Incident",
|
||||
"Incident Updates": "Incident Updates",
|
||||
"Incidents": "Incidents",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Last Updated",
|
||||
"Latency": "Latency",
|
||||
"Latency Embed": "Latency Embed",
|
||||
@@ -47,15 +57,19 @@
|
||||
"Light": "Light",
|
||||
"Live Status": "Live Status",
|
||||
"Loading your preferences...": "Loading your preferences...",
|
||||
"maintenance": "Maintenance",
|
||||
"MAINTENANCE": "MAINTENANCE",
|
||||
"Maintenance Updates": "Maintenance Updates",
|
||||
"Maintenances": "Maintenances",
|
||||
"Major System Outage": "Major System Outage",
|
||||
"Manage Site": "Manage Site",
|
||||
"Manage your notification preferences.": "Manage your notification preferences.",
|
||||
"Max Latency": "Max Latency",
|
||||
"Maximum Latency": "Max Latency",
|
||||
"Min Latency": "Min Latency",
|
||||
"Minimum Latency": "Min Latency",
|
||||
"Minute-by-minute status data for this day": "Minute-by-minute status data for this day",
|
||||
"MONITORING": "MONITORING",
|
||||
"Network error. Please try again.": "Network error. Please try again.",
|
||||
"No Events in %currentMonth": "No Events in %currentMonth",
|
||||
"No events to show": "No events to show",
|
||||
@@ -73,6 +87,7 @@
|
||||
"Notifications": "Notifications",
|
||||
"One-time": "One-time",
|
||||
"Ongoing": "Ongoing",
|
||||
"Operational": "Operational",
|
||||
"Partial Degraded Performance": "Partial Degraded Performance",
|
||||
"Partial System Outage": "Partial System Outage",
|
||||
"Past": "Past",
|
||||
@@ -82,7 +97,11 @@
|
||||
"Please enter the 6-digit verification code": "Please enter the 6-digit verification code",
|
||||
"Read less": "Read less",
|
||||
"Read more": "Read more",
|
||||
"READY": "READY",
|
||||
"Recurring": "Recurring",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"SCHEDULED": "SCHEDULED",
|
||||
"Scheduled Events (%count)": "Scheduled Events (%count)",
|
||||
"Script": "Script",
|
||||
"Select Language": "Select Language",
|
||||
"Select latency metric to display": "Select latency metric to display",
|
||||
@@ -102,7 +121,10 @@
|
||||
"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)",
|
||||
"Uptime": "Uptime",
|
||||
|
||||
+31
-9
@@ -8,14 +8,21 @@
|
||||
"Avg Latency": "Latencia promedio",
|
||||
"Back": "Atrás",
|
||||
"Badges": "Insignias",
|
||||
"CANCELLED": "CANCELADO",
|
||||
"COMPLETED": "COMPLETADO",
|
||||
"Continue": "Continuar",
|
||||
"Copied": "Copiado",
|
||||
"Current": "Actual",
|
||||
"Dark": "Oscuro",
|
||||
"Day": "Día",
|
||||
"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",
|
||||
@@ -32,11 +39,14 @@
|
||||
"Get notified about incidents and scheduled maintenance.": "Recibe notificaciones sobre incidencias y mantenimientos programados.",
|
||||
"Get notified about incidents updates": "Recibir notificaciones sobre actualizaciones de incidentes",
|
||||
"Get notified about scheduled maintenance": "Recibir notificaciones sobre el mantenimiento programado",
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "marco flotante",
|
||||
"Impact": "Impacto",
|
||||
"incident": "Incidente",
|
||||
"Incident Updates": "Actualizaciones de incidentes",
|
||||
"Incidents": "Incidentes",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Última actualización",
|
||||
"Latency": "Estado latente",
|
||||
"Latency Embed": "Incrustación de latencia",
|
||||
@@ -47,32 +57,37 @@
|
||||
"Light": "Luz",
|
||||
"Live Status": "Estado en vivo",
|
||||
"Loading your preferences...": "Cargando tus preferencias...",
|
||||
"maintenance": "Mantenimiento",
|
||||
"MAINTENANCE": "MANTENIMIENTO",
|
||||
"Maintenance Updates": "Actualizaciones de mantenimiento",
|
||||
"Maintenances": "Mantenimientos",
|
||||
"Major System Outage": "Interrupción importante del sistema",
|
||||
"Manage Site": "Gestionar sitio",
|
||||
"Manage your notification preferences.": "Administre sus preferencias de notificación.",
|
||||
"Max Latency": "Latencia máxima",
|
||||
"Maximum Latency": "Latencia máxima",
|
||||
"Min Latency": "Latencia mínima",
|
||||
"Minimum Latency": "Latencia mínima",
|
||||
"Minute-by-minute status data for this day": "Datos del estado minuto a minuto de este día",
|
||||
"MONITORING": "MONITORING",
|
||||
"Network error. Please try again.": "Error de red. ",
|
||||
"No Events in %currentMonth": "No hay eventos en %currentMonth",
|
||||
"No events to show": "No events to show",
|
||||
"No events to show": "No hay eventos para mostrar",
|
||||
"No incidents for this day": "No hay incidencias para este día",
|
||||
"No latency data available for this day": "No hay datos de latencia disponibles para este día",
|
||||
"No maintenances for this day": "No hay mantenimientos para este día.",
|
||||
"No monitors affected": "Ningún monitor afectado",
|
||||
"No monitors available.": "No monitors available.",
|
||||
"No monitors available.": "No hay monitores disponibles.",
|
||||
"No ongoing maintenances": "Sin mantenimientos continuos",
|
||||
"No past maintenances": "Sin mantenimientos pasados",
|
||||
"No Status Available": "Estado no disponible",
|
||||
"No upcoming maintenances": "No hay mantenimientos próximos",
|
||||
"No Updates": "No Updates",
|
||||
"No Updates": "Sin actualizaciones",
|
||||
"No updates yet": "Aún no hay actualizaciones",
|
||||
"Notifications": "Notifications",
|
||||
"Notifications": "Notificaciones",
|
||||
"One-time": "una sola vez",
|
||||
"Ongoing": "En curso",
|
||||
"Operational": "Operativo",
|
||||
"Partial Degraded Performance": "Rendimiento parcialmente degradado",
|
||||
"Partial System Outage": "Interrupción parcial del sistema",
|
||||
"Past": "Pasado",
|
||||
@@ -82,10 +97,14 @@
|
||||
"Please enter the 6-digit verification code": "Por favor ingresa el código de verificación de 6 dígitos",
|
||||
"Read less": "Leer menos",
|
||||
"Read more": "Leer más",
|
||||
"Recurring": "Recurring",
|
||||
"READY": "LISTO",
|
||||
"Recurring": "Recurrente",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"SCHEDULED": "PROGRAMADO",
|
||||
"Scheduled Events (%count)": "Eventos programados (%count)",
|
||||
"Script": "Guion",
|
||||
"Select Language": "Seleccionar idioma",
|
||||
"Select latency metric to display": "Select latency metric to display",
|
||||
"Select latency metric to display": "Selecciona la métrica de latencia para mostrar",
|
||||
"Select Range": "Seleccionar rango",
|
||||
"Sending...": "Envío...",
|
||||
"Standard": "Estándar",
|
||||
@@ -97,13 +116,16 @@
|
||||
"Subscribe": "Suscribir",
|
||||
"Subscribe to Updates": "Suscríbete a las actualizaciones",
|
||||
"There are no incidents or maintenances scheduled for this month.": "No hay incidencias ni mantenimientos programados para este mes.",
|
||||
"There are no ongoing incidents or maintenance events.": "There are no ongoing incidents or maintenance events.",
|
||||
"There are no ongoing incidents or maintenance events.": "No hay incidentes ni mantenimientos en curso.",
|
||||
"Total Incidents": "Incidentes totales",
|
||||
"Total Maintenances": "Mantenimientos totales",
|
||||
"Under Maintenance": "En mantenimiento",
|
||||
"Unknown impact": "Impacto desconocido",
|
||||
"UP": "ACTIVO",
|
||||
"Upcoming": "Próximo",
|
||||
"Updates": "Updates",
|
||||
"Update Incident": "Actualizar incidente",
|
||||
"Update Maintenance": "Actualizar mantenimiento",
|
||||
"Updates": "Actualizaciones",
|
||||
"Updates (%count)": "Actualizaciones (%count)",
|
||||
"Uptime": "tiempo de actividad",
|
||||
"Uptime Badge": "Insignia de tiempo de actividad",
|
||||
|
||||
+31
-9
@@ -8,14 +8,21 @@
|
||||
"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": "تعبیه مانیتور",
|
||||
@@ -32,11 +39,14 @@
|
||||
"Get notified about incidents and scheduled maintenance.": "در مورد حوادث و تعمیر و نگهداری برنامه ریزی شده مطلع شوید.",
|
||||
"Get notified about incidents updates": "در مورد به روز رسانی حوادث مطلع شوید",
|
||||
"Get notified about scheduled maintenance": "در مورد تعمیر و نگهداری برنامه ریزی شده مطلع شوید",
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "آی فریم",
|
||||
"Impact": "تاثیر",
|
||||
"incident": "رخداد",
|
||||
"Incident Updates": "به روز رسانی حادثه",
|
||||
"Incidents": "حوادث",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "آخرین به روز رسانی",
|
||||
"Latency": "تأخیر",
|
||||
"Latency Embed": "تعبیه تاخیر",
|
||||
@@ -47,32 +57,37 @@
|
||||
"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": "MONITORING",
|
||||
"Network error. Please try again.": "خطای شبکه ",
|
||||
"No Events in %currentMonth": "هیچ رویدادی در %currentMonth وجود ندارد",
|
||||
"No events to show": "No events to show",
|
||||
"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 monitors available.",
|
||||
"No monitors available.": "هیچ مانیتوری در دسترس نیست.",
|
||||
"No ongoing maintenances": "بدون تعمیر و نگهداری مداوم",
|
||||
"No past maintenances": "بدون تعمیر و نگهداری قبلی",
|
||||
"No Status Available": "وضعیتی در دسترس نیست",
|
||||
"No upcoming maintenances": "بدون تعمیر و نگهداری آینده",
|
||||
"No Updates": "No Updates",
|
||||
"No Updates": "بدون بهروزرسانی",
|
||||
"No updates yet": "هنوز به روز رسانی نشده است",
|
||||
"Notifications": "Notifications",
|
||||
"Notifications": "اعلانها",
|
||||
"One-time": "یک بار",
|
||||
"Ongoing": "در حال انجام است",
|
||||
"Operational": "عملیاتی",
|
||||
"Partial Degraded Performance": "کاهش عملکرد جزئی",
|
||||
"Partial System Outage": "قطعی جزئی سیستم",
|
||||
"Past": "گذشته",
|
||||
@@ -82,10 +97,14 @@
|
||||
"Please enter the 6-digit verification code": "لطفا کد تایید 6 رقمی را وارد کنید",
|
||||
"Read less": "کمتر بخوانید",
|
||||
"Read more": "ادامه مطلب",
|
||||
"Recurring": "Recurring",
|
||||
"READY": "آماده",
|
||||
"Recurring": "دورهای",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"SCHEDULED": "زمانبندی شده",
|
||||
"Scheduled Events (%count)": "رویدادهای زمانبندی شده (%count)",
|
||||
"Script": "اسکریپت",
|
||||
"Select Language": "زبان را انتخاب کنید",
|
||||
"Select latency metric to display": "Select latency metric to display",
|
||||
"Select latency metric to display": "معیار تأخیر برای نمایش را انتخاب کنید",
|
||||
"Select Range": "Range را انتخاب کنید",
|
||||
"Sending...": "ارسال...",
|
||||
"Standard": "استاندارد",
|
||||
@@ -97,13 +116,16 @@
|
||||
"Subscribe": "مشترک شوید",
|
||||
"Subscribe to Updates": "مشترک شدن در به روز رسانی",
|
||||
"There are no incidents or maintenances scheduled for this month.": "هیچ حادثه یا تعمیراتی برای این ماه برنامه ریزی نشده است.",
|
||||
"There are no ongoing incidents or maintenance events.": "There are no ongoing incidents or maintenance events.",
|
||||
"There are no ongoing incidents or maintenance events.": "هیچ رخداد یا رویداد نگهداری فعالی وجود ندارد.",
|
||||
"Total Incidents": "مجموع حوادث",
|
||||
"Total Maintenances": "کل تعمیر و نگهداری",
|
||||
"Under Maintenance": "تحت تعمیر و نگهداری",
|
||||
"Unknown impact": "تاثیر نامعلوم",
|
||||
"UP": "فعال",
|
||||
"Upcoming": "آینده",
|
||||
"Updates": "Updates",
|
||||
"Update Incident": "بهروزرسانی رویداد",
|
||||
"Update Maintenance": "بهروزرسانی نگهداری",
|
||||
"Updates": "بهروزرسانیها",
|
||||
"Updates (%count)": "بهروزرسانیها (%count)",
|
||||
"Uptime": "آپتایم",
|
||||
"Uptime Badge": "نشان Uptime",
|
||||
|
||||
+35
-13
@@ -3,19 +3,26 @@
|
||||
"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",
|
||||
"Copied": "Copié",
|
||||
"Current": "En cours",
|
||||
"Dark": "Sombre",
|
||||
"Day": "Jour",
|
||||
"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",
|
||||
@@ -32,11 +39,14 @@
|
||||
"Get notified about incidents and scheduled maintenance.": "Soyez informé des incidents et des maintenances planifiées.",
|
||||
"Get notified about incidents updates": "Soyez informé des mises à jour des incidents",
|
||||
"Get notified about scheduled maintenance": "Soyez informé de la maintenance planifiée",
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Impact",
|
||||
"incident": "Incident",
|
||||
"Incident Updates": "Mises à jour des incidents",
|
||||
"Incidents": "Incidents",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Dernière mise à jour",
|
||||
"Latency": "Latence",
|
||||
"Latency Embed": "Latence intégrée",
|
||||
@@ -47,45 +57,54 @@
|
||||
"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",
|
||||
"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.",
|
||||
"Max Latency": "Latence maximale",
|
||||
"Maximum Latency": "Latence maximale",
|
||||
"Min Latency": "Latence minimale",
|
||||
"Minimum Latency": "Latence minimale",
|
||||
"Minute-by-minute status data for this day": "Données d'état minute par minute pour cette journée",
|
||||
"MONITORING": "MONITORING",
|
||||
"Network error. Please try again.": "Erreur réseau. ",
|
||||
"No Events in %currentMonth": "Aucun événement dans %currentMonth",
|
||||
"No events to show": "No events to show",
|
||||
"No events to show": "Aucun événement à afficher",
|
||||
"No incidents for this day": "Aucun incident pour cette journée",
|
||||
"No latency data available for this day": "Aucune donnée de latence disponible pour ce jour",
|
||||
"No maintenances for this day": "Aucune maintenance pour cette journée",
|
||||
"No monitors affected": "Aucun moniteur affecté",
|
||||
"No monitors available.": "No monitors available.",
|
||||
"No monitors available.": "Aucun moniteur disponible.",
|
||||
"No ongoing maintenances": "Aucune maintenance en cours",
|
||||
"No past maintenances": "Aucune maintenance passée",
|
||||
"No Status Available": "Aucun statut disponible",
|
||||
"No upcoming maintenances": "Aucune maintenance à venir",
|
||||
"No Updates": "No Updates",
|
||||
"No Updates": "Aucune mise à jour",
|
||||
"No updates yet": "Aucune mise à jour pour l'instant",
|
||||
"Notifications": "Notifications",
|
||||
"Notifications": "Alertes",
|
||||
"One-time": "Une fois",
|
||||
"Ongoing": "En cours",
|
||||
"Operational": "Opérationnel",
|
||||
"Partial Degraded Performance": "Performance partiellement dégradée",
|
||||
"Partial System Outage": "Panne partielle du système",
|
||||
"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",
|
||||
"Recurring": "Recurring",
|
||||
"READY": "PRÊT",
|
||||
"Recurring": "Récurrent",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"SCHEDULED": "PLANIFIÉ",
|
||||
"Scheduled Events (%count)": "Événements planifiés (%count)",
|
||||
"Script": "Scénario",
|
||||
"Select Language": "Sélectionnez la langue",
|
||||
"Select latency metric to display": "Select latency metric to display",
|
||||
"Select latency metric to display": "Sélectionnez la métrique de latence à afficher",
|
||||
"Select Range": "Sélectionner une plage",
|
||||
"Sending...": "Envoi...",
|
||||
"Standard": "Standard",
|
||||
@@ -97,13 +116,16 @@
|
||||
"Subscribe": "S'abonner",
|
||||
"Subscribe to Updates": "Abonnez-vous aux mises à jour",
|
||||
"There are no incidents or maintenances scheduled for this month.": "Il n’y a aucun incident ou maintenance prévu pour ce mois.",
|
||||
"There are no ongoing incidents or maintenance events.": "There are no ongoing incidents or maintenance events.",
|
||||
"There are no ongoing incidents or maintenance events.": "Il n’y a aucun incident ni aucune maintenance en cours.",
|
||||
"Total Incidents": "Nombre total d'incidents",
|
||||
"Total Maintenances": "Entretiens totaux",
|
||||
"Under Maintenance": "En maintenance",
|
||||
"Unknown impact": "Impact inconnu",
|
||||
"UP": "OPÉRATIONNEL",
|
||||
"Upcoming": "Prochain",
|
||||
"Updates": "Updates",
|
||||
"Update Incident": "Mettre à jour l'incident",
|
||||
"Update Maintenance": "Mettre à jour la maintenance",
|
||||
"Updates": "Mises à jour",
|
||||
"Updates (%count)": "Mises à jour (%count)",
|
||||
"Uptime": "Temps de disponibilité",
|
||||
"Uptime Badge": "Badge de disponibilité",
|
||||
|
||||
+39
-17
@@ -8,14 +8,21 @@
|
||||
"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": "मॉनिटर एम्बेड करें",
|
||||
@@ -32,11 +39,14 @@
|
||||
"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": "Included Monitors",
|
||||
"Included Monitors (%count)": "शामिल मॉनिटर (%count)",
|
||||
"INVESTIGATING": "जाँच कर रहे हैं",
|
||||
"Last Updated": "अंतिम अपडेट",
|
||||
"Latency": "लेटेंसी",
|
||||
"Latency Embed": "लेटेंसी एम्बेड",
|
||||
@@ -47,32 +57,37 @@
|
||||
"Light": "लाइट",
|
||||
"Live Status": "लाइव स्थिति",
|
||||
"Loading your preferences...": "आपकी प्राथमिकताएँ लोड हो रही हैं...",
|
||||
"maintenance": "रखरखाव",
|
||||
"MAINTENANCE": "रखरखाव",
|
||||
"Maintenance Updates": "रखरखाव अपडेट",
|
||||
"Maintenances": "रखरखाव",
|
||||
"Major System Outage": "सिस्टम का बड़ा आउटेज",
|
||||
"Manage Site": "साइट प्रबंधित करें",
|
||||
"Manage your notification preferences.": "अपनी सूचना प्राथमिकताएँ प्रबंधित करें।",
|
||||
"Max Latency": "Max Latency",
|
||||
"Maximum Latency": "Max Latency",
|
||||
"Min Latency": "Min Latency",
|
||||
"Minimum Latency": "Min Latency",
|
||||
"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 events to show",
|
||||
"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 monitors available.",
|
||||
"No monitors available.": "कोई मॉनिटर उपलब्ध नहीं है।",
|
||||
"No ongoing maintenances": "कोई चल रहा रखरखाव नहीं",
|
||||
"No past maintenances": "कोई बीता हुआ रखरखाव नहीं",
|
||||
"No Status Available": "स्थिति उपलब्ध नहीं है",
|
||||
"No upcoming maintenances": "कोई आने वाला रखरखाव नहीं",
|
||||
"No Updates": "No Updates",
|
||||
"No Updates": "कोई अपडेट नहीं",
|
||||
"No updates yet": "अभी तक कोई अपडेट नहीं",
|
||||
"Notifications": "Notifications",
|
||||
"Notifications": "सूचनाएँ",
|
||||
"One-time": "एक बार",
|
||||
"Ongoing": "चल रहा है",
|
||||
"Operational": "चालू",
|
||||
"Partial Degraded Performance": "आंशिक प्रदर्शन गिरावट",
|
||||
"Partial System Outage": "सिस्टम का आंशिक आउटेज.",
|
||||
"Past": "बीता हुआ",
|
||||
@@ -82,10 +97,14 @@
|
||||
"Please enter the 6-digit verification code": "कृपया 6 अंकों का सत्यापन कोड दर्ज करें",
|
||||
"Read less": "कम पढ़ें",
|
||||
"Read more": "और पढ़ें",
|
||||
"Recurring": "Recurring",
|
||||
"READY": "तैयार",
|
||||
"Recurring": "आवर्ती",
|
||||
"RESOLVED": "सुलझा हुआ",
|
||||
"SCHEDULED": "अनुसूचित",
|
||||
"Scheduled Events (%count)": "अनुसूचित कार्यक्रम (%count)",
|
||||
"Script": "स्क्रिप्ट",
|
||||
"Select Language": "भाषा चुनें",
|
||||
"Select latency metric to display": "Select latency metric to display",
|
||||
"Select latency metric to display": "दिखाने के लिए विलंबता मेट्रिक चुनें",
|
||||
"Select Range": "रेंज चुनें",
|
||||
"Sending...": "भेजा जा रहा है...",
|
||||
"Standard": "मानक",
|
||||
@@ -97,19 +116,22 @@
|
||||
"Subscribe": "सब्सक्राइब करें",
|
||||
"Subscribe to Updates": "अपडेट के लिए सब्सक्राइब करें",
|
||||
"There are no incidents or maintenances scheduled for this month.": "इस महीने के लिए कोई घटना या रखरखाव निर्धारित नहीं है।",
|
||||
"There are no ongoing incidents or maintenance events.": "There are no ongoing incidents or maintenance events.",
|
||||
"Total Incidents": "Total Incidents",
|
||||
"Total Maintenances": "Total Maintenances",
|
||||
"There are no ongoing incidents or maintenance events.": "कोई चल रही घटना या रखरखाव इवेंट नहीं है।",
|
||||
"Total Incidents": "कुल घटनाएँ",
|
||||
"Total Maintenances": "कुल रखरखाव",
|
||||
"Under Maintenance": "रखरखाव जारी है",
|
||||
"Unknown impact": "अज्ञात प्रभाव",
|
||||
"UP": "चालू",
|
||||
"Upcoming": "आने वाला",
|
||||
"Updates": "Updates",
|
||||
"Update Incident": "घटना अपडेट करें",
|
||||
"Update Maintenance": "रखरखाव अपडेट करें",
|
||||
"Updates": "अपडेट्स",
|
||||
"Updates (%count)": "अपडेट (%count)",
|
||||
"Uptime": "अपटाइम",
|
||||
"Uptime Badge": "अपटाइम बैज",
|
||||
"Verification failed": "सत्यापन विफल रहा",
|
||||
"Verify": "Verify",
|
||||
"Verifying": "Verifying",
|
||||
"Verify": "सत्यापित करें",
|
||||
"Verifying": "सत्यापित हो रहा है",
|
||||
"We sent a 6-digit code to": "हमने 6 अंकों का कोड भेजा है"
|
||||
}
|
||||
}
|
||||
|
||||
+31
-9
@@ -8,14 +8,21 @@
|
||||
"Avg Latency": "Latenza media",
|
||||
"Back": "Indietro",
|
||||
"Badges": "Distintivi",
|
||||
"CANCELLED": "ANNULLATO",
|
||||
"COMPLETED": "COMPLETATO",
|
||||
"Continue": "Continuare",
|
||||
"Copied": "Copiato",
|
||||
"Current": "Corrente",
|
||||
"Dark": "Buio",
|
||||
"Day": "Giorno",
|
||||
"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",
|
||||
@@ -32,11 +39,14 @@
|
||||
"Get notified about incidents and scheduled maintenance.": "Ricevi notifiche sugli incidenti e sulla manutenzione programmata.",
|
||||
"Get notified about incidents updates": "Ricevi notifiche sugli aggiornamenti degli incidenti",
|
||||
"Get notified about scheduled maintenance": "Ricevi notifiche sulla manutenzione programmata",
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Impatto",
|
||||
"incident": "Incidente",
|
||||
"Incident Updates": "Aggiornamenti sugli incidenti",
|
||||
"Incidents": "Incidenti",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "Ultimo aggiornamento",
|
||||
"Latency": "Latenza",
|
||||
"Latency Embed": "Incorporamento latenza",
|
||||
@@ -47,32 +57,37 @@
|
||||
"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",
|
||||
"Major System Outage": "Interruzione grave del sistema",
|
||||
"Manage Site": "Gestisci sito",
|
||||
"Manage your notification preferences.": "Gestisci le tue preferenze di notifica.",
|
||||
"Max Latency": "Latenza massima",
|
||||
"Maximum Latency": "Latenza massima",
|
||||
"Min Latency": "Latenza minima",
|
||||
"Minimum Latency": "Latenza minima",
|
||||
"Minute-by-minute status data for this day": "Dati sullo stato minuto per minuto per questo giorno",
|
||||
"MONITORING": "MONITORING",
|
||||
"Network error. Please try again.": "Errore di rete. ",
|
||||
"No Events in %currentMonth": "Nessun evento in %currentMonth",
|
||||
"No events to show": "No events to show",
|
||||
"No events to show": "Nessun evento da mostrare",
|
||||
"No incidents for this day": "Nessun incidente per questa giornata",
|
||||
"No latency data available for this day": "Nessun dato sulla latenza disponibile per questo giorno",
|
||||
"No maintenances for this day": "Nessuna manutenzione per oggi",
|
||||
"No monitors affected": "Nessun monitor interessato",
|
||||
"No monitors available.": "No monitors available.",
|
||||
"No monitors available.": "Nessun monitor disponibile.",
|
||||
"No ongoing maintenances": "Nessuna manutenzione continua",
|
||||
"No past maintenances": "Nessuna manutenzione passata",
|
||||
"No Status Available": "Nessuno stato disponibile",
|
||||
"No upcoming maintenances": "Nessuna manutenzione imminente",
|
||||
"No Updates": "No Updates",
|
||||
"No Updates": "Nessun aggiornamento",
|
||||
"No updates yet": "Nessun aggiornamento ancora",
|
||||
"Notifications": "Notifications",
|
||||
"Notifications": "Notifiche",
|
||||
"One-time": "Una volta",
|
||||
"Ongoing": "In corso",
|
||||
"Operational": "Operativo",
|
||||
"Partial Degraded Performance": "Prestazioni parzialmente degradate",
|
||||
"Partial System Outage": "Interruzione parziale del sistema",
|
||||
"Past": "Passato",
|
||||
@@ -82,10 +97,14 @@
|
||||
"Please enter the 6-digit verification code": "Inserisci il codice di verifica di 6 cifre",
|
||||
"Read less": "Leggi di meno",
|
||||
"Read more": "Per saperne di più",
|
||||
"Recurring": "Recurring",
|
||||
"READY": "PRONTO",
|
||||
"Recurring": "Ricorrente",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"SCHEDULED": "PROGRAMMATO",
|
||||
"Scheduled Events (%count)": "Eventi programmati (%count)",
|
||||
"Script": "Copione",
|
||||
"Select Language": "Seleziona lingua",
|
||||
"Select latency metric to display": "Select latency metric to display",
|
||||
"Select latency metric to display": "Seleziona la metrica di latenza da visualizzare",
|
||||
"Select Range": "Seleziona Intervallo",
|
||||
"Sending...": "Invio...",
|
||||
"Standard": "Standard",
|
||||
@@ -97,13 +116,16 @@
|
||||
"Subscribe": "Iscriviti",
|
||||
"Subscribe to Updates": "Iscriviti agli aggiornamenti",
|
||||
"There are no incidents or maintenances scheduled for this month.": "Non ci sono incidenti o manutenzioni programmate per questo mese.",
|
||||
"There are no ongoing incidents or maintenance events.": "There are no ongoing incidents or maintenance events.",
|
||||
"There are no ongoing incidents or maintenance events.": "Non ci sono incidenti o manutenzioni in corso.",
|
||||
"Total Incidents": "Incidenti totali",
|
||||
"Total Maintenances": "Manutenzioni totali",
|
||||
"Under Maintenance": "In manutenzione",
|
||||
"Unknown impact": "Impatto sconosciuto",
|
||||
"UP": "ATTIVO",
|
||||
"Upcoming": "Prossimamente",
|
||||
"Updates": "Updates",
|
||||
"Update Incident": "Aggiorna incidente",
|
||||
"Update Maintenance": "Aggiorna manutenzione",
|
||||
"Updates": "Aggiornamenti",
|
||||
"Updates (%count)": "Aggiornamenti (%count)",
|
||||
"Uptime": "Tempo di attività",
|
||||
"Uptime Badge": "Badge di operatività",
|
||||
|
||||
+31
-9
@@ -8,14 +8,21 @@
|
||||
"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": "埋め込みモニター",
|
||||
@@ -32,11 +39,14 @@
|
||||
"Get notified about incidents and scheduled maintenance.": "インシデントや定期メンテナンスに関する通知を受け取ります。",
|
||||
"Get notified about incidents updates": "インシデントの更新に関する通知を受け取る",
|
||||
"Get notified about scheduled maintenance": "定期メンテナンスに関する通知を受け取る",
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "インパクト",
|
||||
"incident": "インシデント",
|
||||
"Incident Updates": "インシデントの最新情報",
|
||||
"Incidents": "事件",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "最終更新日",
|
||||
"Latency": "レイテンシー",
|
||||
"Latency Embed": "レイテンシの埋め込み",
|
||||
@@ -47,32 +57,37 @@
|
||||
"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": "MONITORING",
|
||||
"Network error. Please try again.": "ネットワークエラー。",
|
||||
"No Events in %currentMonth": "%currentMonth にはイベントがありません",
|
||||
"No events to show": "No events to show",
|
||||
"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 monitors available.",
|
||||
"No monitors available.": "利用可能なモニターはありません。",
|
||||
"No ongoing maintenances": "継続的なメンテナンスはありません",
|
||||
"No past maintenances": "過去のメンテナンスはありません",
|
||||
"No Status Available": "ステータス情報はありません",
|
||||
"No upcoming maintenances": "今後のメンテナンスはありません",
|
||||
"No Updates": "No Updates",
|
||||
"No Updates": "更新はありません",
|
||||
"No updates yet": "まだ更新はありません",
|
||||
"Notifications": "Notifications",
|
||||
"Notifications": "通知",
|
||||
"One-time": "一度",
|
||||
"Ongoing": "進行中",
|
||||
"Operational": "稼働中",
|
||||
"Partial Degraded Performance": "部分的なパフォーマンス低下",
|
||||
"Partial System Outage": "部分的なシステム障害。",
|
||||
"Past": "過去",
|
||||
@@ -82,10 +97,14 @@
|
||||
"Please enter the 6-digit verification code": "6桁の認証コードを入力してください",
|
||||
"Read less": "読む量を減らす",
|
||||
"Read more": "続きを読む",
|
||||
"Recurring": "Recurring",
|
||||
"READY": "準備完了",
|
||||
"Recurring": "繰り返し",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"SCHEDULED": "予定済み",
|
||||
"Scheduled Events (%count)": "予定イベント (%count)",
|
||||
"Script": "スクリプト",
|
||||
"Select Language": "言語の選択",
|
||||
"Select latency metric to display": "Select latency metric to display",
|
||||
"Select latency metric to display": "表示するレイテンシ指標を選択",
|
||||
"Select Range": "範囲の選択",
|
||||
"Sending...": "送信中...",
|
||||
"Standard": "標準",
|
||||
@@ -97,13 +116,16 @@
|
||||
"Subscribe": "購読する",
|
||||
"Subscribe to Updates": "アップデートを購読する",
|
||||
"There are no incidents or maintenances scheduled for this month.": "今月は予定されているインシデントやメンテナンスはありません。",
|
||||
"There are no ongoing incidents or maintenance events.": "There are no ongoing incidents or maintenance events.",
|
||||
"There are no ongoing incidents or maintenance events.": "進行中のインシデントまたはメンテナンスイベントはありません。",
|
||||
"Total Incidents": "総インシデント数",
|
||||
"Total Maintenances": "トータルメンテナンス",
|
||||
"Under Maintenance": "メンテナンス中",
|
||||
"Unknown impact": "未知の影響",
|
||||
"UP": "正常",
|
||||
"Upcoming": "今後の予定",
|
||||
"Updates": "Updates",
|
||||
"Update Incident": "インシデントを更新",
|
||||
"Update Maintenance": "メンテナンスを更新",
|
||||
"Updates": "更新",
|
||||
"Updates (%count)": "アップデート (%count)",
|
||||
"Uptime": "稼働時間",
|
||||
"Uptime Badge": "稼働時間バッジ",
|
||||
|
||||
+31
-9
@@ -8,14 +8,21 @@
|
||||
"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": "모니터 내장",
|
||||
@@ -32,11 +39,14 @@
|
||||
"Get notified about incidents and scheduled maintenance.": "사고 및 예정된 유지 관리에 대한 알림을 받으세요.",
|
||||
"Get notified about incidents updates": "사건 업데이트에 대한 알림 받기",
|
||||
"Get notified about scheduled maintenance": "예정된 유지 관리에 대한 알림 받기",
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"iFrame": "아이프레임",
|
||||
"Impact": "영향",
|
||||
"incident": "인시던트",
|
||||
"Incident Updates": "사고 업데이트",
|
||||
"Incidents": "사건",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "마지막 업데이트",
|
||||
"Latency": "숨어 있음",
|
||||
"Latency Embed": "지연 시간 포함",
|
||||
@@ -47,32 +57,37 @@
|
||||
"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": "MONITORING",
|
||||
"Network error. Please try again.": "네트워크 오류입니다. 다시 시도해 주세요.",
|
||||
"No Events in %currentMonth": "%currentMonth에 이벤트가 없습니다.",
|
||||
"No events to show": "No events to show",
|
||||
"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 monitors available.",
|
||||
"No monitors available.": "사용 가능한 모니터가 없습니다.",
|
||||
"No ongoing maintenances": "지속적인 유지 관리가 필요하지 않습니다.",
|
||||
"No past maintenances": "과거 유지보수 없음",
|
||||
"No Status Available": "사용 가능한 상태 정보 없음",
|
||||
"No upcoming maintenances": "예정된 유지 관리가 없습니다.",
|
||||
"No Updates": "No Updates",
|
||||
"No Updates": "업데이트 없음",
|
||||
"No updates yet": "아직 업데이트가 없습니다",
|
||||
"Notifications": "Notifications",
|
||||
"Notifications": "알림",
|
||||
"One-time": "일회성",
|
||||
"Ongoing": "전진",
|
||||
"Operational": "정상",
|
||||
"Partial Degraded Performance": "부분적 성능 저하",
|
||||
"Partial System Outage": "부분적 시스템 장애.",
|
||||
"Past": "과거",
|
||||
@@ -82,10 +97,14 @@
|
||||
"Please enter the 6-digit verification code": "인증번호 6자리를 입력해주세요",
|
||||
"Read less": "덜 읽으세요",
|
||||
"Read more": "더 읽어보세요",
|
||||
"Recurring": "Recurring",
|
||||
"READY": "준비됨",
|
||||
"Recurring": "반복",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"SCHEDULED": "예정됨",
|
||||
"Scheduled Events (%count)": "예정된 이벤트 (%count)",
|
||||
"Script": "스크립트",
|
||||
"Select Language": "언어 선택",
|
||||
"Select latency metric to display": "Select latency metric to display",
|
||||
"Select latency metric to display": "표시할 지연 시간 지표를 선택하세요",
|
||||
"Select Range": "범위 선택",
|
||||
"Sending...": "배상...",
|
||||
"Standard": "기준",
|
||||
@@ -97,13 +116,16 @@
|
||||
"Subscribe": "구독하다",
|
||||
"Subscribe to Updates": "업데이트 구독",
|
||||
"There are no incidents or maintenances scheduled for this month.": "이번 달에는 예정된 사고나 점검이 없습니다.",
|
||||
"There are no ongoing incidents or maintenance events.": "There are no ongoing incidents or maintenance events.",
|
||||
"There are no ongoing incidents or maintenance events.": "진행 중인 인시던트 또는 유지보수 이벤트가 없습니다.",
|
||||
"Total Incidents": "총 사고",
|
||||
"Total Maintenances": "총 유지보수",
|
||||
"Under Maintenance": "유지보수 중",
|
||||
"Unknown impact": "알 수 없는 영향",
|
||||
"UP": "정상",
|
||||
"Upcoming": "예정",
|
||||
"Updates": "Updates",
|
||||
"Update Incident": "인시던트 업데이트",
|
||||
"Update Maintenance": "유지보수 업데이트",
|
||||
"Updates": "업데이트",
|
||||
"Updates (%count)": "업데이트(%count)",
|
||||
"Uptime": "가동 시간",
|
||||
"Uptime Badge": "가동 시간 배지",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user