mirror of
https://github.com/rajnandan1/kener.git
synced 2026-08-07 15:27:13 +00:00
Compare commits
257 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db6cb6cf7d | |||
| babeeb75b2 | |||
| e0187605e7 | |||
| e2861f1e59 | |||
| 01aa4d9984 | |||
| 555372f175 | |||
| bd5fd409d1 | |||
| f61d82e13f | |||
| f39588fff7 | |||
| 0716f271df | |||
| 1085c3e561 | |||
| 93907e6d96 | |||
| d03fd63c64 | |||
| 3ff43af787 | |||
| 7f2fef8e8e | |||
| 7658170865 | |||
| b1f8a505a5 | |||
| 104da58646 | |||
| ed6e97e8c9 | |||
| 0b25274874 | |||
| bb8ab41bfe | |||
| b43a5fb343 | |||
| 554caa5018 | |||
| ffe7403043 | |||
| a26d0ece59 | |||
| a4277f7ed0 | |||
| f75aaf9cef | |||
| 4e1ecf41ee | |||
| f23fb5313c | |||
| 18489c5339 | |||
| 4044bae26d | |||
| 02686caa78 | |||
| 797aef80d6 | |||
| a8841ad8a3 | |||
| 0b2cd5fc8a | |||
| 9d349716e5 | |||
| 92d068ef49 | |||
| c6e3620151 | |||
| d92165d0f8 | |||
| a56bbead8d | |||
| db3fb923f0 | |||
| bb9d88a095 | |||
| 912da6b8f4 | |||
| 3b07623346 | |||
| 702ceca9b0 | |||
| 21f2433919 | |||
| 4e7791b104 | |||
| f79c24c80d | |||
| 087c2f25fb | |||
| 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 | |||
| b970c5bba1 | |||
| 7e7c0eb429 | |||
| f0ab8f25e7 | |||
| 87896ce2a9 | |||
| fae9f38d90 | |||
| 1df62bc315 | |||
| 9199b807d5 | |||
| 3363e90007 | |||
| 78bad84f0e | |||
| 50482aa21d | |||
| 339d653bab | |||
| 585db91263 | |||
| cbc48d2ddc | |||
| 1c6bed1297 | |||
| bf126e7151 | |||
| e5e244e2dc | |||
| 4b23e729c1 | |||
| c9c675da28 | |||
| 1c485ac376 | |||
| 9a8bf8431b | |||
| 3aedb9be11 | |||
| 58f0e9f98a | |||
| 88072b7de7 | |||
| 9129e9606c | |||
| b088a79143 | |||
| 14d46cb3de | |||
| 5989e5b8cb | |||
| 7b3afa4467 | |||
| d758de0752 | |||
| e6424b1d66 | |||
| 5a95c81574 | |||
| d170ba69b7 | |||
| d04ee0a933 | |||
| 444fe5890a | |||
| d1eb9266ea | |||
| 275c9d7ff4 | |||
| 552bdc09ad | |||
| 09afbbd17d | |||
| 6ea045d152 | |||
| 8fd8aa4bcf | |||
| 366c2c0a22 | |||
| 21f06a4143 | |||
| d99da146bd | |||
| 290113d553 | |||
| 1f370b3eea | |||
| 17ed56c682 | |||
| 2377a4faa6 | |||
| 32081056c7 | |||
| 5f86ca27e1 | |||
| c58530cda3 | |||
| 9b99bec50b | |||
| 8b189b686e | |||
| 430733bd41 | |||
| 6699887a75 | |||
| 8a498e902d | |||
| eed71d3633 | |||
| 228808a267 | |||
| ee1adf8330 | |||
| 9e8b0962a0 | |||
| 72a28cac61 | |||
| c759ade7b9 | |||
| 7392b968ef | |||
| 7f328023ea | |||
| 2749b8017c | |||
| c4aff9f81b | |||
| c4739e0257 | |||
| 9c8d4bcb89 | |||
| d300f3cdae | |||
| b14a5c9f5f | |||
| 3936edb017 | |||
| 71a9157abf | |||
| 3b571f404c | |||
| 6ff6e595a6 | |||
| bc7c23c1f0 |
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -5,382 +5,82 @@ description: Specialized skill for creating and editing high-quality Kener docum
|
||||
|
||||
# Documentation Writer
|
||||
|
||||
## Overview
|
||||
Use this skill for all docs edits in `src/routes/(docs)/docs/content/` and when updating docs navigation in `src/routes/(docs)/docs.json`.
|
||||
|
||||
This skill provides guidelines and best practices for creating high-quality, easy-to-follow documentation for Kener. Use this skill when creating new documentation or editing existing documentation files.
|
||||
## Non-negotiable rules
|
||||
|
||||
## Documentation Structure
|
||||
1. **Be concise**: remove repetition and background that does not help the user complete a task.
|
||||
2. **Be actionable**: prioritize “what to do” over theory.
|
||||
3. **One source of truth**: if another page already has details, link to it instead of duplicating.
|
||||
4. **Preserve structure**: keep valid frontmatter and heading anchor IDs.
|
||||
5. **Keep examples copyable**: minimal, tested-looking, and directly relevant.
|
||||
6. **Search before writing**: always check if the content already exists in some form before adding new sections or pages.
|
||||
7. **Check Relevant Code**: Search the codebase inside `src/` for any relevant code, comments, or tests that can inform the documentation content and ensure accuracy.
|
||||
|
||||
### Content Location
|
||||
## Docs config model (current)
|
||||
|
||||
All documentation files are located in:
|
||||
```
|
||||
src/routes/(docs)/docs/content/
|
||||
```
|
||||
`docs.json` is versioned. Sidebar lives inside tabs:
|
||||
|
||||
### File Organization
|
||||
- `versions[].content.navigation.tabs[].sidebar`
|
||||
- Sidebar groups contain `pages`
|
||||
- Page paths use `content` (legacy `slug` may still appear in older content)
|
||||
|
||||
```
|
||||
content/
|
||||
├── introduction.md # Top-level pages
|
||||
├── configuration.md
|
||||
├── monitors/ # Nested sections
|
||||
│ ├── overview.md
|
||||
│ ├── api.md
|
||||
│ └── ping.md
|
||||
├── alerting/
|
||||
│ ├── overview.md
|
||||
│ └── triggers.md
|
||||
└── setup/
|
||||
├── email-setup.md
|
||||
└── database-setup.md
|
||||
```
|
||||
When adding a new doc page, add it to the appropriate tab sidebar path.
|
||||
|
||||
### Navigation Configuration
|
||||
## Versioned link policy (mandatory)
|
||||
|
||||
Navigation is controlled by `/src/routes/(docs)/docs.json`:
|
||||
- For v4 docs content, internal links MUST use explicit v4 paths: `/docs/v4/...`.
|
||||
- Do not use unversioned shortcuts like `/docs/alerting/...` in v4 pages.
|
||||
- Before finalizing, verify every internal link in edited files resolves to the intended version.
|
||||
|
||||
```json
|
||||
{
|
||||
"sidebar": [
|
||||
{
|
||||
"group": "Getting Started",
|
||||
"collapsible": false,
|
||||
"pages": [
|
||||
{
|
||||
"title": "Introduction",
|
||||
"slug": "introduction"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: When creating new documentation files, you MUST also update `docs.json` to add navigation entries.
|
||||
|
||||
## Required Frontmatter
|
||||
|
||||
Every documentation file MUST begin with YAML frontmatter:
|
||||
## Required page format
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Your Page Title
|
||||
description: A clear, concise description of the page content (used for SEO and previews)
|
||||
title: Page Title
|
||||
description: One-line summary of user outcome
|
||||
---
|
||||
```
|
||||
|
||||
## Custom Heading Anchors
|
||||
- Use custom anchors for H2/H3 headings: `## Section {#section}`
|
||||
- Use GitHub admonitions only when needed: `[!NOTE]`, `[!IMPORTANT]`, `[!WARNING]`, `[!CAUTION]`, `[!TIP]`
|
||||
- Prefer short sections and short lists
|
||||
|
||||
Use custom heading IDs for stable deep linking. This is CRITICAL for maintaining links even if heading text changes.
|
||||
## Preferred structure (default)
|
||||
|
||||
### Syntax
|
||||
1. Short intro (1–2 sentences)
|
||||
2. Quick setup / minimum config
|
||||
3. Required variables/options table
|
||||
4. Verification step
|
||||
5. Top troubleshooting items
|
||||
|
||||
```markdown
|
||||
## Section Title {#section-title}
|
||||
```
|
||||
Only add extra sections if they materially improve task completion.
|
||||
|
||||
### Best Practices
|
||||
## Keep docs lean
|
||||
|
||||
1. **Always include custom IDs** for all H2 and H3 headings
|
||||
2. Use lowercase, kebab-case format
|
||||
3. Keep IDs short but descriptive
|
||||
4. IDs should be unique within the document
|
||||
Remove or avoid:
|
||||
|
||||
### Examples
|
||||
- Multiple near-identical examples
|
||||
- Long conceptual explainers
|
||||
- Platform-by-platform repetition unless behavior differs
|
||||
- Large checklists that restate earlier content
|
||||
|
||||
```markdown
|
||||
## How API Monitoring Works {#how-api-monitoring-works}
|
||||
### Configuration Options {#configuration-options}
|
||||
### Common Issues {#common-issues}
|
||||
```
|
||||
## Editing workflow
|
||||
|
||||
## Markdown Features
|
||||
1. Read the whole target document.
|
||||
2. Compress verbose sections first.
|
||||
3. Keep critical caveats and breaking notes.
|
||||
4. Ensure internal links and anchors still work.
|
||||
5. If adding files, update `docs.json` navigation in the correct version/tab.
|
||||
|
||||
### Callout Boxes
|
||||
## Review checklist
|
||||
|
||||
Use GitHub-flavored callout syntax for important information:
|
||||
|
||||
```markdown
|
||||
> [!NOTE]
|
||||
> Additional context or helpful information
|
||||
|
||||
> [!WARNING]
|
||||
> Caution about potential issues
|
||||
|
||||
> [!TIP]
|
||||
> Helpful tips for users
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Critical information users should know
|
||||
```
|
||||
|
||||
### Code Blocks
|
||||
|
||||
Always specify the language for syntax highlighting:
|
||||
|
||||
````markdown
|
||||
```javascript
|
||||
// Your code here
|
||||
```
|
||||
|
||||
```bash
|
||||
# Shell commands
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"config": "value"
|
||||
}
|
||||
```
|
||||
````
|
||||
|
||||
### Tables
|
||||
|
||||
Use tables for structured information:
|
||||
|
||||
```markdown
|
||||
| Column 1 | Column 2 | Column 3 |
|
||||
| :------- | :------- | :------- |
|
||||
| Value 1 | Value 2 | Value 3 |
|
||||
```
|
||||
|
||||
### Internal Links
|
||||
|
||||
Reference other documentation pages using absolute paths:
|
||||
|
||||
```markdown
|
||||
See the [Email Setup](/docs/setup/email-setup) guide for details.
|
||||
```
|
||||
|
||||
For section anchors:
|
||||
|
||||
```markdown
|
||||
See [Configuration Options](/docs/monitors/api#configuration-options).
|
||||
```
|
||||
|
||||
## Documentation Structure Template
|
||||
|
||||
Follow this structure for comprehensive documentation pages:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Feature Name
|
||||
description: Brief description of the feature
|
||||
---
|
||||
|
||||
Brief introduction paragraph explaining what the feature is and why it matters.
|
||||
|
||||
## How It Works {#how-it-works}
|
||||
|
||||
Explain the basic workflow or concept.
|
||||
|
||||
## Configuration Options {#configuration-options}
|
||||
|
||||
Document all available configuration options using tables:
|
||||
|
||||
| Field | Type | Description | Default |
|
||||
| :------- | :------- | :---------- | :------ |
|
||||
| `option` | `string` | What it does | `value` |
|
||||
|
||||
## Examples {#examples}
|
||||
|
||||
Provide practical examples with clear explanations.
|
||||
|
||||
### 1. Basic Example {#basic-example}
|
||||
|
||||
Simple use case with minimal configuration.
|
||||
|
||||
### 2. Advanced Example {#advanced-example}
|
||||
|
||||
More complex use case demonstrating advanced features.
|
||||
|
||||
## Best Practices {#best-practices}
|
||||
|
||||
### Topic 1 {#best-practices-topic-1}
|
||||
|
||||
Practical advice for optimal usage.
|
||||
|
||||
## Troubleshooting {#troubleshooting}
|
||||
|
||||
### Common Issues {#common-issues}
|
||||
|
||||
| Issue | Possible Cause | Solution |
|
||||
| :---- | :------------- | :------- |
|
||||
| Problem | Why it happens | How to fix |
|
||||
|
||||
## Next Steps {#next-steps}
|
||||
|
||||
- [Related Documentation 1](/docs/path/to/doc1)
|
||||
- [Related Documentation 2](/docs/path/to/doc2)
|
||||
```
|
||||
|
||||
## Quality Guidelines
|
||||
|
||||
### Writing Style
|
||||
|
||||
1. **Be Clear and Concise**: Use simple language and short sentences
|
||||
2. **Be Action-Oriented**: Focus on what users need to do
|
||||
3. **Be Consistent**: Use the same terms throughout documentation
|
||||
4. **Be Complete**: Don't assume prior knowledge
|
||||
|
||||
### Content Requirements
|
||||
|
||||
1. **Start with Context**: Explain why the feature exists
|
||||
2. **Show Examples**: Always include practical examples
|
||||
3. **Include Edge Cases**: Document common pitfalls
|
||||
4. **Link Related Topics**: Help users navigate to related information
|
||||
|
||||
### Technical Writing
|
||||
|
||||
1. **Use Active Voice**: "Configure the monitor" not "The monitor should be configured"
|
||||
2. **Use Present Tense**: "Kener sends notifications" not "Kener will send notifications"
|
||||
3. **Be Specific**: Include exact values, paths, and commands
|
||||
4. **Test All Code**: Ensure all code examples work
|
||||
|
||||
## Avoid Duplication
|
||||
|
||||
### Before Creating New Documentation
|
||||
|
||||
1. **Search Existing Docs**: Check if the topic is already covered
|
||||
2. **Use References**: If content exists elsewhere, link to it instead of duplicating
|
||||
3. **Extend Existing Docs**: Consider adding to an existing page rather than creating a new one
|
||||
|
||||
### When Content Overlaps
|
||||
|
||||
**Instead of duplicating**:
|
||||
```markdown
|
||||
## Email Configuration
|
||||
|
||||
To send emails, you need to configure SMTP settings:
|
||||
- SMTP_HOST=smtp.example.com
|
||||
- SMTP_PORT=587
|
||||
...
|
||||
```
|
||||
|
||||
**Use references**:
|
||||
```markdown
|
||||
## Email Configuration
|
||||
|
||||
Kener supports email notifications through SMTP or Resend. For detailed configuration instructions, see the [Email Setup](/docs/setup/email-setup) guide.
|
||||
```
|
||||
|
||||
### When to Duplicate vs Reference
|
||||
|
||||
**Duplicate when**:
|
||||
- The information is critical to understanding the current topic
|
||||
- The content is very brief (1-2 sentences)
|
||||
- The duplicate provides necessary context
|
||||
|
||||
**Reference when**:
|
||||
- Detailed configuration steps exist elsewhere
|
||||
- The topic is comprehensively covered in another document
|
||||
- The information would make the current document too long
|
||||
|
||||
## Workflow
|
||||
|
||||
### Creating New Documentation
|
||||
|
||||
1. **Plan the Structure**: Outline sections before writing
|
||||
2. **Check for Existing Content**: Ensure you're not duplicating
|
||||
3. **Write the Content**: Follow the template and guidelines
|
||||
4. **Add Custom Heading IDs**: Include `{#id}` for all major headings
|
||||
5. **Update docs.json**: Add navigation entry
|
||||
6. **Add Internal Links**: Link to related documentation
|
||||
7. **Review and Test**: Verify all links, code examples, and formatting
|
||||
|
||||
### Editing Existing Documentation
|
||||
|
||||
1. **Read the Full Document**: Understand the existing content
|
||||
2. **Maintain Consistency**: Match the existing style and tone
|
||||
3. **Update Related Sections**: Keep all sections consistent
|
||||
4. **Preserve Custom IDs**: Never change existing `{#custom-ids}`
|
||||
5. **Update Links**: Ensure all internal references remain valid
|
||||
|
||||
### Before Finalizing
|
||||
|
||||
**Checklist**:
|
||||
- [ ] Frontmatter includes title and description
|
||||
- [ ] All H2 and H3 headings have custom IDs
|
||||
- [ ] Code blocks specify language
|
||||
- [ ] Tables are properly formatted
|
||||
- [ ] Internal links use `/docs/` prefix
|
||||
- [ ] Examples are practical and tested
|
||||
- [ ] No duplicate content (or justified duplication)
|
||||
- [ ] Navigation entry added to docs.json (for new pages)
|
||||
- [ ] Related documentation is linked
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Configuration Documentation
|
||||
|
||||
Always document environment variables with tables:
|
||||
|
||||
```markdown
|
||||
### Environment Variables {#environment-variables}
|
||||
|
||||
| Variable | Description | Required | Default |
|
||||
| :------- | :---------- | :------- | :------ |
|
||||
| `VAR_NAME` | What it does | Yes/No | `value` |
|
||||
```
|
||||
|
||||
### API Documentation
|
||||
|
||||
Structure API documentation consistently:
|
||||
|
||||
```markdown
|
||||
## Endpoint Name {#endpoint-name}
|
||||
|
||||
Description of what the endpoint does.
|
||||
|
||||
**Endpoint**: `POST /api/path`
|
||||
|
||||
**Authentication**: Required/Not Required
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"field": "value"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"result": "success"
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
### Monitor Type Documentation
|
||||
|
||||
Follow this structure for monitor types:
|
||||
|
||||
1. Brief introduction
|
||||
2. How it works
|
||||
3. Configuration options (table)
|
||||
4. Examples (multiple, increasing complexity)
|
||||
5. Best practices (with sub-sections)
|
||||
6. Troubleshooting (table format)
|
||||
|
||||
## File Naming Conventions
|
||||
|
||||
- Use lowercase
|
||||
- Use hyphens for spaces: `email-setup.md`
|
||||
- Be descriptive: `creating-managing.md` not `create.md`
|
||||
- Match the slug in docs.json
|
||||
|
||||
## Examples of High-Quality Documentation
|
||||
|
||||
Reference these examples when writing:
|
||||
- `/docs/content/setup/email-setup.md` - Comprehensive setup guide
|
||||
- `/docs/content/monitors/api.md` - Feature documentation with examples
|
||||
- `/docs/content/alerting/overview.md` - Conceptual overview
|
||||
|
||||
## Key Principles
|
||||
|
||||
1. **User-First**: Write for users of all skill levels
|
||||
2. **Scannable**: Use headings, lists, and tables for easy scanning
|
||||
3. **Actionable**: Users should know what to do after reading
|
||||
4. **Maintainable**: Use references to avoid update cascades
|
||||
5. **Accessible**: Clear language, good structure, proper formatting
|
||||
- [ ] Title/description frontmatter exists
|
||||
- [ ] Key steps are clear and copyable
|
||||
- [ ] Content is concise and non-duplicative
|
||||
- [ ] Headings keep stable custom anchors
|
||||
- [ ] Navigation updated (if new page)
|
||||
- [ ] Internal links point to correct paths
|
||||
- [ ] v4 pages use `/docs/v4/...` internal links (no unversioned `/docs/...` shortcuts)
|
||||
- [ ] No outdated or irrelevant content remains
|
||||
- [ ] Admonitions used appropriately for important notes
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/ss-shadcn-svelte
|
||||
@@ -0,0 +1,361 @@
|
||||
---
|
||||
name: tailwindcss
|
||||
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.
|
||||
user-invokable: false
|
||||
metadata:
|
||||
category: styling
|
||||
---
|
||||
|
||||
# Tailwind CSS v4 Development Guidelines
|
||||
|
||||
Best practices for using Tailwind CSS v4 utility classes effectively.
|
||||
|
||||
**Note**: Tailwind CSS v4 (released January 2025) uses a CSS-first configuration approach. If you need v3 compatibility, tailwind.config.js is still supported.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Utility-First**: Use utility classes instead of custom CSS
|
||||
2. **Mobile-First**: Design for mobile, then scale up with responsive modifiers
|
||||
3. **Component Extraction**: Extract repeated patterns into components
|
||||
4. **Consistent Spacing**: Use Tailwind's spacing scale
|
||||
5. **Custom Configuration**: Extend the default theme for brand consistency
|
||||
|
||||
## Basic Utilities
|
||||
|
||||
### Layout
|
||||
|
||||
```tsx
|
||||
// Flexbox
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1">Content</div>
|
||||
<div className="flex-shrink-0">Sidebar</div>
|
||||
</div>
|
||||
|
||||
// Grid
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
</div>
|
||||
|
||||
// Positioning
|
||||
<div className="relative">
|
||||
<div className="absolute top-0 right-0">Badge</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Spacing
|
||||
|
||||
```tsx
|
||||
// Padding and Margin
|
||||
<div className="p-4 m-2"> {/* padding: 1rem, margin: 0.5rem */}
|
||||
<div className="px-6 py-4"> {/* padding-x: 1.5rem, padding-y: 1rem */}
|
||||
<div className="mt-8 mb-4"> {/* margin-top: 2rem, margin-bottom: 1rem */}
|
||||
|
||||
// Space between children
|
||||
<div className="space-y-4"> {/* margin-bottom on all but last child */}
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Typography
|
||||
|
||||
```tsx
|
||||
<h1 className="text-4xl font-bold text-gray-900">Heading</h1>
|
||||
<p className="text-base font-normal text-gray-600 leading-relaxed">
|
||||
Paragraph text with comfortable line height.
|
||||
</p>
|
||||
<span className="text-sm font-medium text-blue-600">Label</span>
|
||||
```
|
||||
|
||||
### Colors
|
||||
|
||||
```tsx
|
||||
// Text colors
|
||||
<p className="text-gray-900 dark:text-gray-100">Text</p>
|
||||
|
||||
// Background colors
|
||||
<div className="bg-blue-500 hover:bg-blue-600">Button</div>
|
||||
|
||||
// Border colors
|
||||
<div className="border border-gray-300">Box</div>
|
||||
```
|
||||
|
||||
## Responsive Design
|
||||
|
||||
### Breakpoints
|
||||
|
||||
```tsx
|
||||
// Mobile-first responsive classes
|
||||
<div className="w-full md:w-1/2 lg:w-1/3">
|
||||
{/* Full width on mobile, half on medium screens, third on large */}
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl md:text-4xl lg:text-6xl">
|
||||
{/* Responsive text sizes */}
|
||||
</h1>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Responsive grid */}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Container
|
||||
|
||||
```tsx
|
||||
<div className="container mx-auto px-4">
|
||||
{/* Centered container with horizontal padding */}
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Responsive container padding */}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Button
|
||||
|
||||
```tsx
|
||||
<button className="px-4 py-2 bg-blue-600 text-white font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
Click me
|
||||
</button>
|
||||
|
||||
// Variants
|
||||
<button className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50">
|
||||
Secondary
|
||||
</button>
|
||||
```
|
||||
|
||||
### Card
|
||||
|
||||
```tsx
|
||||
<div className="overflow-hidden rounded-lg bg-white shadow-md">
|
||||
<img src="/image.jpg" alt="" className="h-48 w-full object-cover" />
|
||||
<div className="p-6">
|
||||
<h2 className="mb-2 text-xl font-semibold">Card Title</h2>
|
||||
<p className="text-gray-600">Card content goes here.</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Form Input
|
||||
|
||||
```tsx
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
<p className="text-sm text-gray-500">We'll never share your email.</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
## State Variants
|
||||
|
||||
### Hover, Focus, Active
|
||||
|
||||
```tsx
|
||||
<button className="bg-blue-500 hover:bg-blue-600 active:bg-blue-700 focus:ring-2 focus:ring-blue-500">
|
||||
Interactive Button
|
||||
</button>
|
||||
|
||||
<a href="#" className="text-blue-600 hover:text-blue-800 hover:underline">
|
||||
Link
|
||||
</a>
|
||||
```
|
||||
|
||||
### Group Hover
|
||||
|
||||
```tsx
|
||||
<div className="group">
|
||||
<img src="/image.jpg" className="transition-opacity group-hover:opacity-75" />
|
||||
<p className="group-hover:text-blue-600">Hover the container</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Disabled
|
||||
|
||||
```tsx
|
||||
<button className="disabled:cursor-not-allowed disabled:opacity-50" disabled>
|
||||
Disabled Button
|
||||
</button>
|
||||
```
|
||||
|
||||
## Dark Mode
|
||||
|
||||
```css
|
||||
/* Tailwind v4: Configure in app/globals.css */
|
||||
@import "tailwindcss";
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/* Or use class-based: .dark */
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Usage (same as v3)
|
||||
<div className="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
|
||||
<h1 className="text-gray-900 dark:text-white">Title</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">Description</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Custom Styles
|
||||
|
||||
### Arbitrary Values
|
||||
|
||||
```tsx
|
||||
<div className="top-[117px]"> {/* Custom top value */}
|
||||
<div className="bg-[#1da1f2]"> {/* Custom color */}
|
||||
<div className="grid-cols-[200px_1fr]"> {/* Custom grid template */}
|
||||
```
|
||||
|
||||
### @apply Directive
|
||||
|
||||
```css
|
||||
/* components/button.css */
|
||||
.btn-primary {
|
||||
@apply rounded-md bg-blue-600 px-4 py-2 font-medium text-white;
|
||||
@apply hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:outline-none;
|
||||
@apply disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Tailwind v4: CSS-First Configuration
|
||||
|
||||
```css
|
||||
/* app/globals.css */
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Custom colors */
|
||||
--color-brand-50: #eff6ff;
|
||||
--color-brand-100: #dbeafe;
|
||||
--color-brand-900: #1e3a8a;
|
||||
|
||||
/* Custom spacing */
|
||||
--spacing-128: 32rem;
|
||||
|
||||
/* Custom fonts */
|
||||
--font-family-sans: "Inter", sans-serif;
|
||||
|
||||
/* Custom breakpoints */
|
||||
--breakpoint-3xl: 1920px;
|
||||
}
|
||||
```
|
||||
|
||||
### Tailwind v3 Config (Still Supported)
|
||||
|
||||
```javascript
|
||||
// tailwind.config.js (optional in v4)
|
||||
module.exports = {
|
||||
content: ["./app/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: "#eff6ff",
|
||||
100: "#dbeafe",
|
||||
900: "#1e3a8a"
|
||||
}
|
||||
},
|
||||
spacing: {
|
||||
128: "32rem"
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Inter", "sans-serif"]
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [require("@tailwindcss/forms"), require("@tailwindcss/typography")]
|
||||
}
|
||||
```
|
||||
|
||||
## Plugins
|
||||
|
||||
### Official Plugins
|
||||
|
||||
```bash
|
||||
npm install @tailwindcss/forms
|
||||
npm install @tailwindcss/typography
|
||||
npm install @tailwindcss/aspect-ratio
|
||||
npm install @tailwindcss/container-queries
|
||||
```
|
||||
|
||||
```tsx
|
||||
// @tailwindcss/forms
|
||||
<input type="text" className="form-input rounded-md" />
|
||||
|
||||
// @tailwindcss/typography
|
||||
<article className="prose lg:prose-xl">
|
||||
<h1>Article Title</h1>
|
||||
<p>Content...</p>
|
||||
</article>
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
### Automatic Content Detection
|
||||
|
||||
Tailwind v4 automatically detects and scans all template files - no `content` configuration needed.
|
||||
|
||||
### Build Performance
|
||||
|
||||
Tailwind v4 delivers 3.5x faster full builds (~100ms) compared to v3 using modern CSS features like `@property` and `color-mix()`.
|
||||
|
||||
**Browser Requirements**: Safari 16.4+, Chrome 111+, Firefox 128+
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Centered Content
|
||||
|
||||
```tsx
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div>Centered content</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Sticky Header
|
||||
|
||||
```tsx
|
||||
<header className="sticky top-0 z-50 border-b bg-white">
|
||||
<nav>Navigation</nav>
|
||||
</header>
|
||||
```
|
||||
|
||||
### Grid Layout
|
||||
|
||||
```tsx
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Truncate Text
|
||||
|
||||
```tsx
|
||||
<p className="truncate">This text will be truncated with ellipsis if too long</p>
|
||||
<p className="line-clamp-3">This text will show max 3 lines with ellipsis</p>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Consistent Spacing**: Stick to Tailwind's spacing scale
|
||||
2. **Responsive by Default**: Always consider mobile-first design
|
||||
3. **Extract Components**: Avoid repeating long class lists
|
||||
4. **Use Theme Colors**: Define custom colors in config, not arbitrary values
|
||||
5. **Leverage @apply Sparingly**: Prefer utility classes in JSX
|
||||
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
|
||||
+44
-2
@@ -1,9 +1,51 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Version control
|
||||
.git
|
||||
.github
|
||||
|
||||
# IDE and editor
|
||||
.vscode
|
||||
dist
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Build outputs (rebuilt inside Docker)
|
||||
build
|
||||
dist
|
||||
.svelte-kit
|
||||
.docs-excluded
|
||||
|
||||
# Environment and secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
*.log
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Docker files (prevent recursive context)
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
|
||||
# Documentation and meta
|
||||
README.md
|
||||
README.template.md
|
||||
AGENTS.md
|
||||
CHANGELOG.md
|
||||
LICENSE
|
||||
check-output.txt
|
||||
|
||||
# AI / tooling config
|
||||
.claude
|
||||
|
||||
# Test artifacts
|
||||
*.test.*
|
||||
*.spec.*
|
||||
@@ -0,0 +1,3 @@
|
||||
KENER_SECRET_KEY=some_secret_key_for_kener
|
||||
REDIS_URL=redis://localhost:6379
|
||||
ORIGIN=http://localhost:3000
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: Kener v4 items
|
||||
about: Kener v4 items
|
||||
title: ''
|
||||
labels: kener_v4
|
||||
assignees: rajnandan1
|
||||
|
||||
---
|
||||
|
||||
Kener v4 items
|
||||
@@ -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,7 +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.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
name: Create Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version (for example: 4.0.0)"
|
||||
required: true
|
||||
type: string
|
||||
make_latest:
|
||||
description: "Mark this release as latest"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
prerelease:
|
||||
description: "Mark as pre-release"
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
name: Bump version, tag, and create release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out default branch
|
||||
uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate version format
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "Invalid version format: $VERSION"
|
||||
echo "Use semver like 4.0.0 or 4.0.0-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Ensure release tag does not already exist
|
||||
run: |
|
||||
TAG="v${{ inputs.version }}"
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Tag $TAG already exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Bump package version
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
CURRENT_VERSION=$(node -p 'require("./package.json").version')
|
||||
|
||||
if [ "$CURRENT_VERSION" != "$VERSION" ]; then
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
else
|
||||
echo "package.json already at version $VERSION"
|
||||
fi
|
||||
|
||||
- name: Commit version bump
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
git add package.json
|
||||
if [ -f package-lock.json ]; then
|
||||
git add package-lock.json
|
||||
fi
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "chore(release): bump version to $VERSION"
|
||||
fi
|
||||
|
||||
- name: Create and push git tag
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
TAG="v$VERSION"
|
||||
|
||||
git tag -a "$TAG" -m "Release $TAG"
|
||||
git push origin "HEAD:${{ github.event.repository.default_branch }}"
|
||||
git push origin "$TAG"
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ inputs.version }}
|
||||
target_commitish: ${{ github.event.repository.default_branch }}
|
||||
generate_release_notes: true
|
||||
make_latest: ${{ inputs.make_latest && 'true' || 'false' }}
|
||||
prerelease: ${{ inputs.prerelease }}
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
@@ -1,83 +0,0 @@
|
||||
name: Generate README
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'README.template.md'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'README.template.md'
|
||||
workflow_dispatch: # Allows for manual execution
|
||||
workflow_run: # Triggers this workflow to run when it recognizes 'publish-images' workflow has ran and successfully completed
|
||||
workflows: ["Publish Docker Image to Registries"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: write # Explicitly allow pushing changes
|
||||
|
||||
jobs:
|
||||
generate-readme:
|
||||
name: Generate README from template
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
fetch-depth: 0 # Fetch full history, including tags
|
||||
persist-credentials: false # We'll manually authenticate
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --global user.name "github-actions"
|
||||
git config --global user.email "github-actions@github.com"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install mustache dotenv
|
||||
|
||||
- name: Extract Major and Major-Minor Versions
|
||||
run: |
|
||||
VERSION="${{ vars.BUILD_VERSION }}"
|
||||
|
||||
# Check if VERSION is empty and set a fallback value
|
||||
if [ -z "$VERSION" ]; then
|
||||
# Fetch the latest release using Git
|
||||
VERSION=$(git tag -l --sort=-v:refname | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || echo "3.1.0")
|
||||
fi
|
||||
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2)
|
||||
|
||||
echo "Full version: $VERSION"
|
||||
echo "Major version: $MAJOR"
|
||||
echo "Major-Minor version: $MAJOR_MINOR"
|
||||
|
||||
# Export all as environment variables
|
||||
echo "LATEST_VERSION=$VERSION" >> $GITHUB_ENV
|
||||
echo "LATEST_MAJOR_VERSION=$MAJOR" >> $GITHUB_ENV
|
||||
echo "LATEST_MAJOR_MINOR_VERSION=$MAJOR_MINOR" >> $GITHUB_ENV
|
||||
|
||||
- name: Generate README.md
|
||||
env:
|
||||
BUILD_FULL_VERSION: ${{ env.LATEST_VERSION }} # e.g., 1.2.3
|
||||
BUILD_MAJOR_VERSION: ${{ env.LATEST_MAJOR_VERSION}} # e.g., 1
|
||||
BUILD_MAJOR_MINOR_VERSION: ${{ env.LATEST_MAJOR_MINOR_VERSION }} # e.g., 1.2
|
||||
run: node scripts/generate-readme.js
|
||||
|
||||
- name: Commit and Push Changes
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
git add README.md
|
||||
git commit -m "Auto-generate README.md with release versions" || echo "No changes to commit"
|
||||
git push https://x-access-token:${{ secrets.GH_PAT }}@github.com/${{ github.repository }}.git HEAD:main
|
||||
@@ -1,21 +0,0 @@
|
||||
name: Prevent Direct README Changes
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "README.md"
|
||||
|
||||
jobs:
|
||||
check-readme:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Detect direct README changes
|
||||
run: |
|
||||
if git diff --name-only origin/main | grep -q "README.md"; then
|
||||
echo "❌ Direct modifications to README.md are not allowed!"
|
||||
echo "Please update README.template.md instead."
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,93 +1,58 @@
|
||||
name: Publish Docker Image to Registries
|
||||
name: Publish Nightly Docker Image
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published # Runs only when a GitHub Release is published
|
||||
workflow_dispatch: # Allows for manual execution
|
||||
push:
|
||||
branches:
|
||||
- next/**
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
ALPINE_VERSION: "23-alpine"
|
||||
DEBIAN_VERSION: "23-slim"
|
||||
# Registry URLs
|
||||
DOCKERHUB_REGISTRY: docker.io
|
||||
GITHUB_REGISTRY: ghcr.io
|
||||
# Docker Hub image name (using Docker Hub username)
|
||||
DOCKERHUB_IMAGE_NAME: ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }}
|
||||
# GitHub image name (formatted as `account/repo`)
|
||||
GITHUB_IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
concurrency:
|
||||
group: nightly-docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-lockfile:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '23' # Adjust as needed
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check for lock-file changes
|
||||
run: |
|
||||
git diff --exit-code package-lock.json || (
|
||||
echo "🫥 package-lock.json is outdated or missing. Please run 'npm install' and commit the updated lockfile."
|
||||
exit 1
|
||||
)
|
||||
|
||||
build-and-push-to-registries:
|
||||
needs: check-lockfile # Runs only after `check-lockfile` completes successfully
|
||||
name: Push Docker images to Docker Hub and GitHub Container Registry
|
||||
build-and-push-nightly:
|
||||
name: Build and push nightly Docker images
|
||||
strategy:
|
||||
matrix:
|
||||
variant: [alpine, debian]
|
||||
variant: [debian, alpine]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
contents: read
|
||||
packages: write
|
||||
# This is used to complete the identity challenge with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
# Install the cosign tool (except on PR)
|
||||
# https://github.com/sigstore/cosign-installer
|
||||
- name: Install cosign
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: sigstore/cosign-installer@v3.8.0
|
||||
with:
|
||||
cosign-release: 'v2.2.4'
|
||||
cosign-release: v2.2.4
|
||||
|
||||
# Set up BuildKit Docker container builder to be able to build multi-platform images and export cache
|
||||
# https://github.com/docker/setup-buildx-action
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.8.0
|
||||
|
||||
# Log in to Docker Hub (except on PR)
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3.3.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
# Log in to GitHub Container Registry (except on PR)
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3.3.0
|
||||
with:
|
||||
registry: ${{ env.GITHUB_REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Combined metadata extraction for both registries
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5.6.1
|
||||
@@ -96,61 +61,31 @@ jobs:
|
||||
${{ env.DOCKERHUB_IMAGE_NAME }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.GITHUB_IMAGE_NAME }}
|
||||
tags: |
|
||||
# Debian Variant Tags
|
||||
type=semver,pattern={{version}},enable=${{ matrix.variant == 'debian' }}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ matrix.variant == 'debian' }}
|
||||
type=semver,pattern={{major}},enable=${{ matrix.variant == 'debian' }}
|
||||
type=raw,value=latest,enable=${{ matrix.variant == 'debian' }}
|
||||
|
||||
# Alpine Variant Tags
|
||||
type=semver,pattern={{version}},suffix=-alpine,enable=${{ matrix.variant == 'alpine' }}
|
||||
type=semver,pattern={{major}}.{{minor}},suffix=-alpine,enable=${{ matrix.variant == 'alpine' }}
|
||||
type=semver,pattern={{major}},suffix=-alpine,enable=${{ matrix.variant == 'alpine' }}
|
||||
type=raw,value=alpine,enable=${{ matrix.variant == 'alpine' }}
|
||||
type=raw,value=nightly,enable=${{ matrix.variant == 'debian' }}
|
||||
type=raw,value=nightly-alpine,enable=${{ matrix.variant == 'alpine' }}
|
||||
type=sha,format=short,prefix=nightly-,enable=${{ matrix.variant == 'debian' }}
|
||||
type=sha,format=short,prefix=nightly-alpine-,enable=${{ matrix.variant == 'alpine' }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.3.0
|
||||
|
||||
# Build and push Docker image with Buildx to both registries (don't push on PR)
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v6.13.0
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VARIANT=${{ matrix.variant }}
|
||||
ALPINE_VERSION=${{ env.ALPINE_VERSION }}
|
||||
DEBIAN_VERSION=${{ env.DEBIAN_VERSION }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Sign the resulting Docker image digests
|
||||
- name: Sign the published Docker images
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
env:
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
run: |
|
||||
echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
|
||||
# For use in other workflows (e.g. 'generate-readme', etc.)
|
||||
- name: Save Build Version to Repository Variable
|
||||
if: matrix.variant == 'debian' && github.run_attempt == 1
|
||||
run: |
|
||||
# VERSION="${{ steps.meta.outputs.version }}"
|
||||
VERSION=$(gh release view --json tagName -q .tagName 2>/dev/null || echo "")
|
||||
|
||||
# Check if VERSION is empty and set a fallback value
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(git tag -l --sort=-version:refname | grep -E '^(v)?[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || echo "3.1.0")
|
||||
fi
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
|
||||
echo "Setting BUILD_VERSION to $VERSION"
|
||||
gh variable set BUILD_VERSION --body "$VERSION"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_PAT }} # Needs to be PAT w/ Read access to metadata and secrets & Read and Write access to actions, actions variables, and code
|
||||
@@ -0,0 +1,92 @@
|
||||
name: Publish Main Docker Image (with Docs)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DOCKERHUB_REGISTRY: docker.io
|
||||
GITHUB_REGISTRY: ghcr.io
|
||||
DOCKERHUB_IMAGE_NAME: ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }}
|
||||
GITHUB_IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
concurrency:
|
||||
group: main-docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-push-main:
|
||||
name: Build and push main Docker images (with docs)
|
||||
strategy:
|
||||
matrix:
|
||||
variant: [debian, alpine]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@v3.8.0
|
||||
with:
|
||||
cosign-release: v2.2.4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.8.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3.3.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3.3.0
|
||||
with:
|
||||
registry: ${{ env.GITHUB_REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5.6.1
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKERHUB_IMAGE_NAME }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.GITHUB_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=main-with-docs,enable=${{ matrix.variant == 'debian' }}
|
||||
type=raw,value=main-with-docs-alpine,enable=${{ matrix.variant == 'alpine' }}
|
||||
type=sha,format=short,prefix=main-with-docs-,enable=${{ matrix.variant == 'debian' }}
|
||||
type=sha,format=short,prefix=main-with-docs-alpine-,enable=${{ matrix.variant == 'alpine' }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.3.0
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v6.13.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VARIANT=${{ matrix.variant }}
|
||||
WITH_DOCS=true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Sign the published Docker images
|
||||
env:
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
run: |
|
||||
echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
@@ -0,0 +1,146 @@
|
||||
name: Publish Release Docker Images
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DOCKERHUB_REGISTRY: docker.io
|
||||
GITHUB_REGISTRY: ghcr.io
|
||||
DOCKERHUB_IMAGE_NAME: ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }}
|
||||
GITHUB_IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
concurrency:
|
||||
group: release-docker-${{ github.event.release.tag_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-push-release:
|
||||
name: Build and push release Docker images
|
||||
strategy:
|
||||
matrix:
|
||||
variant: [debian, alpine]
|
||||
base_path: ["", "/status"]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Check out release tag
|
||||
uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
ref: refs/tags/${{ github.event.release.tag_name || github.ref_name }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate package version matches release tag
|
||||
run: |
|
||||
TAG="${{ github.event.release.tag_name || github.ref_name }}"
|
||||
EXPECTED_VERSION="${TAG#v}"
|
||||
PACKAGE_VERSION=$(node -p 'require("./package.json").version')
|
||||
|
||||
if [ "$PACKAGE_VERSION" != "$EXPECTED_VERSION" ]; then
|
||||
echo "package.json version mismatch"
|
||||
echo "release tag: $TAG"
|
||||
echo "expected package.json version: $EXPECTED_VERSION"
|
||||
echo "actual package.json version: $PACKAGE_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@v3.8.0
|
||||
with:
|
||||
cosign-release: v2.2.4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.8.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3.3.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3.3.0
|
||||
with:
|
||||
registry: ${{ env.GITHUB_REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compute release tags
|
||||
id: vars
|
||||
run: |
|
||||
TAG="${{ github.event.release.tag_name || github.ref_name }}"
|
||||
NORM_TAG="${TAG#v}"
|
||||
|
||||
if [ "${{ matrix.base_path }}" = "/status" ]; then
|
||||
BASE_SUFFIX="-status"
|
||||
else
|
||||
BASE_SUFFIX=""
|
||||
fi
|
||||
|
||||
WITH_DOCS="false"
|
||||
|
||||
if [ "${{ matrix.variant }}" = "alpine" ]; then
|
||||
VARIANT_SUFFIX="-alpine"
|
||||
else
|
||||
VARIANT_SUFFIX=""
|
||||
fi
|
||||
|
||||
FULL_SUFFIX="${BASE_SUFFIX}${VARIANT_SUFFIX}"
|
||||
|
||||
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"
|
||||
echo "release_norm_tag_debian_alias=${NORM_TAG}${BASE_SUFFIX}-debian" >> "$GITHUB_OUTPUT"
|
||||
echo "latest_tag_debian_alias=latest${BASE_SUFFIX}-debian" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5.6.1
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKERHUB_IMAGE_NAME }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.GITHUB_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=${{ steps.vars.outputs.latest_tag }}
|
||||
type=raw,value=${{ steps.vars.outputs.release_tag }}
|
||||
type=raw,value=${{ steps.vars.outputs.release_norm_tag }},enable=${{ steps.vars.outputs.release_norm_tag != steps.vars.outputs.release_tag }}
|
||||
type=raw,value=${{ steps.vars.outputs.latest_tag_debian_alias }},enable=${{ matrix.variant == 'debian' }}
|
||||
type=raw,value=${{ steps.vars.outputs.release_tag_debian_alias }},enable=${{ matrix.variant == 'debian' }}
|
||||
type=raw,value=${{ steps.vars.outputs.release_norm_tag_debian_alias }},enable=${{ matrix.variant == 'debian' && steps.vars.outputs.release_norm_tag_debian_alias != steps.vars.outputs.release_tag_debian_alias }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.3.0
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v6.13.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VARIANT=${{ matrix.variant }}
|
||||
WITH_DOCS=${{ steps.vars.outputs.with_docs }}
|
||||
KENER_BASE_PATH=${{ matrix.base_path }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Sign the published Docker images
|
||||
env:
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
run: |
|
||||
echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
@@ -1,4 +1,6 @@
|
||||
.DS_Store
|
||||
.DS_STORE
|
||||
**/.DS_Store
|
||||
node_modules
|
||||
static/kener
|
||||
build
|
||||
@@ -30,3 +32,6 @@ static/uploads/*
|
||||
temp.txt
|
||||
temp.js
|
||||
.DS_Store
|
||||
knip-output.txt
|
||||
check-output.txt
|
||||
translation-report.json
|
||||
+2
-1
@@ -19,4 +19,5 @@ config/static/*
|
||||
!config/static/.kener
|
||||
**/*.yaml
|
||||
**/*.yml
|
||||
.github/
|
||||
.github/
|
||||
src/lib/components/ui
|
||||
@@ -21,3 +21,20 @@ 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:
|
||||
|
||||
- `.claude/skills/documentation-writer/SKILL.md`
|
||||
|
||||
This is mandatory for docs-related tasks. Prioritize short, clear, action-oriented docs and avoid bloat.
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# 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
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# =============================================================================
|
||||
# Kener v4 — Status Page Application
|
||||
# Multi-stage, multi-variant (Alpine / Debian) Dockerfile
|
||||
#
|
||||
# Build:
|
||||
# docker build -t kener . # Alpine (default)
|
||||
# docker build -t kener --build-arg VARIANT=debian . # Debian Slim
|
||||
# docker build -t kener --build-arg WITH_DOCS=true . # Include docs
|
||||
#
|
||||
# Run:
|
||||
# docker run -d -p 3000:3000 \
|
||||
# -e KENER_SECRET_KEY=<secret> \
|
||||
# -e ORIGIN=http://localhost:3000 \
|
||||
# -e REDIS_URL=redis://<host>:6379 \
|
||||
# -v kener_db:/app/database \
|
||||
# kener
|
||||
# =============================================================================
|
||||
|
||||
ARG NODE_VERSION=24
|
||||
ARG VARIANT=alpine
|
||||
ARG WITH_DOCS=false
|
||||
ARG KENER_BASE_PATH=
|
||||
|
||||
# =============================================================================
|
||||
# STAGE 1 — BUILDER (installs deps, compiles native modules, builds app)
|
||||
# =============================================================================
|
||||
|
||||
# ---------- Alpine builder ----------
|
||||
FROM node:${NODE_VERSION}-alpine AS builder-alpine
|
||||
RUN apk add --no-cache \
|
||||
build-base \
|
||||
python3 \
|
||||
sqlite \
|
||||
sqlite-dev \
|
||||
tzdata
|
||||
|
||||
# ---------- Debian builder ----------
|
||||
FROM node:${NODE_VERSION}-slim AS builder-debian
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
python3 \
|
||||
sqlite3 \
|
||||
libsqlite3-dev \
|
||||
tzdata && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---------- Selected variant ----------
|
||||
FROM builder-${VARIANT} AS builder
|
||||
|
||||
ENV NPM_CONFIG_LOGLEVEL=error
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG KENER_BASE_PATH
|
||||
ENV KENER_BASE_PATH=${KENER_BASE_PATH}
|
||||
|
||||
# 1. Copy package manifests first (maximises layer cache hits)
|
||||
COPY package*.json ./
|
||||
|
||||
# 2. Install ALL dependencies (devDependencies needed for the build step)
|
||||
RUN npm ci --no-fund && \
|
||||
npm cache clean --force
|
||||
|
||||
# 3. Copy the rest of the source tree
|
||||
COPY . .
|
||||
|
||||
# 4. Create directories that the app expects
|
||||
RUN mkdir -p database
|
||||
|
||||
# 5. Conditionally remove docs routes before build
|
||||
# (avoids EXDEV rename error in overlayfs; clean .svelte-kit so stale
|
||||
# route types don't persist)
|
||||
ARG WITH_DOCS
|
||||
RUN if [ "$WITH_DOCS" != "true" ]; then \
|
||||
rm -rf src/routes/\(docs\) .svelte-kit; \
|
||||
fi
|
||||
|
||||
# 6. Build: SvelteKit (vite) + server bundle (esbuild)
|
||||
# Use build-with-docs when docs are enabled
|
||||
RUN if [ "$WITH_DOCS" = "true" ]; then \
|
||||
npm run build-with-docs; \
|
||||
else \
|
||||
npm run build; \
|
||||
fi
|
||||
|
||||
# 7. Stage docs runtime files for index-docs (empty dir when docs disabled)
|
||||
RUN mkdir -p /docs-runtime && \
|
||||
if [ "$WITH_DOCS" = "true" ]; then \
|
||||
mkdir -p /docs-runtime/scripts && \
|
||||
mkdir -p /docs-runtime/src/lib && \
|
||||
mkdir -p "/docs-runtime/src/routes/(docs)/docs" && \
|
||||
cp scripts/index-docs.ts /docs-runtime/scripts/ && \
|
||||
cp src/lib/marked.ts /docs-runtime/src/lib/ && \
|
||||
cp "src/routes/(docs)/docs.json" "/docs-runtime/src/routes/(docs)/" && \
|
||||
cp -r "src/routes/(docs)/docs/content" "/docs-runtime/src/routes/(docs)/docs/"; \
|
||||
fi
|
||||
|
||||
# 8. Remove devDependencies from node_modules
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
# =============================================================================
|
||||
# STAGE 2 — PRODUCTION (minimal runtime image)
|
||||
# =============================================================================
|
||||
|
||||
# ---------- Alpine runtime ----------
|
||||
FROM node:${NODE_VERSION}-alpine AS final-alpine
|
||||
RUN apk add --no-cache \
|
||||
sqlite \
|
||||
tzdata \
|
||||
iputils \
|
||||
curl \
|
||||
libcap && \
|
||||
# Grant ping the NET_RAW capability so non-root users can send ICMP packets
|
||||
setcap cap_net_raw+ep /bin/ping || true
|
||||
|
||||
# ---------- Debian runtime ----------
|
||||
FROM node:${NODE_VERSION}-slim AS final-debian
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
sqlite3 \
|
||||
tzdata \
|
||||
iputils-ping \
|
||||
curl \
|
||||
libcap2-bin && \
|
||||
setcap cap_net_raw+ep /usr/bin/ping || true && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---------- Selected variant ----------
|
||||
FROM final-${VARIANT} AS final
|
||||
|
||||
ARG PORT=3000
|
||||
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"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create writable directories owned by the non-root "node" user
|
||||
# (node:node is provided by the official Node.js images)
|
||||
RUN mkdir -p database && \
|
||||
chown -R node:node /app
|
||||
|
||||
# ---- Copy artifacts from builder (order: least → most likely to change) ----
|
||||
|
||||
# Production node_modules (largest layer, changes least often)
|
||||
COPY --chown=node:node --from=builder /app/node_modules ./node_modules
|
||||
|
||||
# Package manifest (needed for ESM "type":"module" resolution)
|
||||
COPY --chown=node:node --from=builder /app/package.json ./package.json
|
||||
|
||||
# Knex migrations & seeds (run at startup by build/main.js)
|
||||
COPY --chown=node:node --from=builder /app/migrations ./migrations
|
||||
COPY --chown=node:node --from=builder /app/seeds ./seeds
|
||||
|
||||
# Seed data files imported by seeds at runtime (all are leaf modules)
|
||||
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/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
|
||||
|
||||
# Docs runtime files (index-docs script + markdown sources; empty when WITH_DOCS=false)
|
||||
COPY --chown=node:node --from=builder /docs-runtime/ ./
|
||||
|
||||
# Entrypoint script (runs index-docs on startup when docs are bundled)
|
||||
COPY --chown=node:node docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
RUN chmod +x docker-entrypoint.sh
|
||||
|
||||
# ---- Runtime configuration ----
|
||||
|
||||
# Switch to non-root user
|
||||
USER node
|
||||
|
||||
EXPOSE ${PORT}
|
||||
|
||||
# Healthcheck: hit the /healthcheck endpoint exposed by Express in main.ts
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD sh -c 'curl -sf http://localhost:${PORT}${KENER_BASE_PATH}/healthcheck || exit 1'
|
||||
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
CMD ["node", "build/main.js"]
|
||||
@@ -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,38 +1,229 @@
|
||||
# sv
|
||||
# Kener - Stunning Status Pages
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
<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>
|
||||
|
||||
## Creating a project
|
||||
<p align="center">
|
||||
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/rajnandan1/kener?label=Star%20Repo&style=social">
|
||||
<a href="https://github.com/ivbeg/awesome-status-pages"><img src="https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg" alt="Awesome status page" /></a>
|
||||
<a href="https://awesome-selfhosted.net/tags/status--uptime-pages.html#kener"><img src="https://awesome.re/mentioned-badge.svg" alt="Awesome self hosted" /></a>
|
||||
</p>
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
<p align="center">
|
||||
<a href="https://hub.docker.com/r/rajnandan1/kener"><img src="https://img.shields.io/docker/pulls/rajnandan1/kener" alt="Docker Kener" /></a>
|
||||
<a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=latest"><img alt="Docker Image Size" src="https://img.shields.io/docker/image-size/rajnandan1/kener/latest?logo=docker&logoColor=white&label=debian" /></a>
|
||||
<a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=alpine"><img alt="Docker Image Size" src="https://img.shields.io/docker/image-size/rajnandan1/kener/alpine?logo=docker&logoColor=white&label=alpine" /></a>
|
||||
</p>
|
||||
|
||||
```sh
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
<p align="center">
|
||||
<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>
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
<p align="center">
|
||||
<a href="https://www.producthunt.com/posts/kener-2" target="_blank">
|
||||
<img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=kener-2&theme=light" alt="Kener on Product Hunt">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f514/512.webp" type="image/webp">
|
||||
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f514/512.gif" alt="🔔" width="32" height="32">
|
||||
</picture>
|
||||
<picture>
|
||||
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f680/512.webp" type="image/webp">
|
||||
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f680/512.gif" alt="🚀" width="32" height="32">
|
||||
</picture>
|
||||
<picture>
|
||||
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f6a7/512.webp" type="image/webp">
|
||||
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f6a7/512.gif" alt="🚧" width="32" height="32">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
| [🌍 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.
|
||||
|
||||
Designed with **ease of use** and **customization in mind**, Kener provides all the essential features you’d expect from a status page—without unnecessary complexity.
|
||||
|
||||
### Why Kener?
|
||||
|
||||
✅ Minimal overhead – Set up quickly with a clean, modern UI<br>
|
||||
✅ Customizable – Easily tailor it to match your brand<br>
|
||||
✅ Open-source & free – Because great tools should be accessible to everyone
|
||||
|
||||
### What's in a Name?
|
||||
|
||||
“Kener” is inspired by the Assamese word _“Kene”_, meaning _“how’s it going?”_. The _‘.ing’_ was added because, well… that domain was available. 😄
|
||||
|
||||
## Quick Start
|
||||
|
||||
Get Kener running in minutes.
|
||||
|
||||
### Docker (recommended)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/rajnandan1/kener.git
|
||||
cd kener
|
||||
|
||||
# Uses docker-compose.yml (includes Redis + Kener)
|
||||
# Set a strong KENER_SECRET_KEY and ORIGIN in docker-compose.yml before first run
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Developing
|
||||
Open `http://localhost:3000`.
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
> [!IMPORTANT]
|
||||
> Set a strong `KENER_SECRET_KEY` and set `ORIGIN` to your public URL before starting for the first time.
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
Use `docker-compose.dev.yml` when you want to build from local source instead of pulling the published image:
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
```
|
||||
|
||||
## Building
|
||||
Or combine both files to keep base production config while overriding Kener with a local build:
|
||||
|
||||
To create a production version of your app:
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||
```
|
||||
|
||||
### Run pre-built image
|
||||
|
||||
You can use either image:
|
||||
|
||||
- `docker.io/rajnandan1/kener:latest`
|
||||
- `ghcr.io/rajnandan1/kener:latest`
|
||||
|
||||
For subpath deployments (`/status`), use:
|
||||
|
||||
- `docker.io/rajnandan1/kener:latest-status`
|
||||
- `docker.io/rajnandan1/kener:latest-status-alpine`
|
||||
- `ghcr.io/rajnandan1/kener:latest-status`
|
||||
- `ghcr.io/rajnandan1/kener:latest-status-alpine`
|
||||
|
||||
```bash
|
||||
mkdir -p database
|
||||
docker run -d \
|
||||
--name kener \
|
||||
-p 3000:3000 \
|
||||
-v "$(pwd)/database:/app/database" \
|
||||
-e "KENER_SECRET_KEY=replace_with_a_random_string" \
|
||||
-e "ORIGIN=http://localhost:3000" \
|
||||
-e "REDIS_URL=redis://host.docker.internal:6379" \
|
||||
docker.io/rajnandan1/kener:latest
|
||||
```
|
||||
|
||||
### Run pre-built subpath image (`/status`)
|
||||
|
||||
```bash
|
||||
mkdir -p database
|
||||
docker run -d \
|
||||
--name kener-status \
|
||||
-p 3000:3000 \
|
||||
-v "$(pwd)/database:/app/database" \
|
||||
-e "KENER_SECRET_KEY=replace_with_a_random_string" \
|
||||
-e "ORIGIN=http://localhost:3000" \
|
||||
-e "KENER_BASE_PATH=/status" \
|
||||
-e "REDIS_URL=redis://host.docker.internal:6379" \
|
||||
docker.io/rajnandan1/kener:latest-status
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> For subpath mode, keep `ORIGIN` as the site origin (`http://localhost:3000`), not `http://localhost:3000/status`.
|
||||
|
||||
### Run without Docker
|
||||
|
||||
Requirements:
|
||||
|
||||
- Node.js `>= 20`
|
||||
- Redis
|
||||
|
||||
```bash
|
||||
git clone https://github.com/rajnandan1/kener.git
|
||||
cd kener
|
||||
npm install
|
||||
|
||||
# Start Redis (example)
|
||||
docker run -d --name kener-redis -p 6379:6379 redis:7-alpine
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
Create a `.env` with at least:
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
```dotenv
|
||||
KENER_SECRET_KEY=replace_with_a_random_string
|
||||
ORIGIN=http://localhost:3000
|
||||
REDIS_URL=redis://localhost:6379
|
||||
PORT=3000
|
||||
```
|
||||
|
||||
For the full quick start (including local Docker builds and dev mode), see the docs:
|
||||
|
||||
- https://kener.ing/docs/v4/getting-started/quick-start
|
||||
|
||||
## Features
|
||||
|
||||
Kener combines public status page essentials with advanced admin workflows.
|
||||
|
||||
### 📊 Monitoring, Reliability, and Communication
|
||||
|
||||
- Monitor **API, Ping, TCP, DNS, SSL, SQL, Heartbeat, and GameDig** checks
|
||||
- Manage incidents with clear timelines, updates, and acknowledgements
|
||||
- Schedule maintenance windows and keep users informed throughout
|
||||
- Send notifications via **Email, Webhook, Slack, and Discord**
|
||||
- Explore historical monitoring data and uptime trends
|
||||
|
||||
### 🎨 Status Page Experience and Branding
|
||||
|
||||
- Build branded, customizable status pages (logo, colors, CSS, themes)
|
||||
- Support **light/dark mode**, localization, and timezone-aware display
|
||||
- Embed status widgets and badges into external sites and portals
|
||||
- Provide SEO-friendly public pages for global audiences
|
||||
|
||||
### 🛠️ Operations, Collaboration, and Automation
|
||||
|
||||
- Invite teams with role-based collaboration across workflows
|
||||
- Manage multiple status pages from one Kener instance
|
||||
- Use trigger-based workflows and template-driven messaging
|
||||
- Manage API keys for secure integrations and automations
|
||||
- Integrate analytics providers like GA, Plausible, Mixpanel, Umami, and Clarity
|
||||
- Access the full REST API for incidents, monitors, and reporting
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- [SvelteKit](https://kit.svelte.dev/)
|
||||
- [shadcn-svelte](https://www.shadcn-svelte.com/)
|
||||
|
||||
## Support Me
|
||||
|
||||
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)
|
||||
|
||||
- [Buy Me a Coffee](https://www.buymeacoffee.com/rajnandan1)
|
||||
|
||||
## Contributing
|
||||
|
||||
If you want to contribute to Kener, please read the [Contribution Guide](https://github.com/rajnandan1/kener/blob/main/.github/CONTRIBUTING.md).
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#rajnandan1/kener&Date)
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
|
||||
> kener@4.0.0 check
|
||||
> svelte-kit sync && svelte-check --tsconfig ./tsconfig.json
|
||||
|
||||
[dotenv@17.2.3] injecting env (19) from .env -- tip: 🔐 prevent committing .env to code: https://dotenvx.com/precommit
|
||||
Loading svelte-check in workspace: /Users/rajnandan1/Code/kener
|
||||
Getting Svelte diagnostics...
|
||||
|
||||
[dotenv@17.2.3] injecting env (19) from .env -- tip: ⚙️ load multiple .env files with { path: ['.env.local', '.env'] }
|
||||
[dotenv@17.2.3] injecting env (0) from .env -- tip: ⚙️ override existing env vars with { override: true }
|
||||
[dotenv@17.2.3] injecting env (0) from .env -- tip: 🔑 add access controls to secrets: https://dotenvx.com/ops
|
||||
/Users/rajnandan1/Code/kener/src/lib/components/ui/chart/chart-container.svelte:20:28
|
||||
Warn: This reference only captures the initial value of `id`. Did you mean to reference it inside a closure instead?
|
||||
https://svelte.dev/e/state_referenced_locally (svelte)
|
||||
|
||||
const chartId = `chart-${id || uid.replace(/:/g, "")}`;
|
||||
|
||||
/Users/rajnandan1/Code/kener/src/routes/(docs)/docs/DocsSidebar.svelte:45:25
|
||||
Warn: This reference only captures the initial value of `currentSlug`. Did you mean to reference it inside a closure instead?
|
||||
https://svelte.dev/e/state_referenced_locally (svelte)
|
||||
let expandedPages = $state<string[]>([]);
|
||||
let prevSlug = $state(currentSlug);
|
||||
|
||||
/Users/rajnandan1/Code/kener/src/routes/(kener)/events/[MMMM]-[YYYY]/+page.svelte:34:28
|
||||
Warn: This reference only captures the initial value of `data`. Did you mean to reference it inside a closure instead?
|
||||
https://svelte.dev/e/state_referenced_locally (svelte)
|
||||
// Parse the current month from params
|
||||
const parsedDate = parse(data.monthParam, "MMMM-yyyy", new Date());
|
||||
const currentMonth = format(parsedDate, "MMMM yyyy");
|
||||
|
||||
====================================
|
||||
svelte-check found 0 errors and 3 warnings in 3 files
|
||||
@@ -0,0 +1,56 @@
|
||||
# =============================================================================
|
||||
# Kener v4 — Development Docker Compose (local build testing)
|
||||
#
|
||||
# Builds the image from the local Dockerfile instead of pulling from a registry.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.dev.yml up -d --build
|
||||
#
|
||||
# Build a specific variant:
|
||||
# docker compose -f docker-compose.dev.yml build --build-arg VARIANT=debian
|
||||
# docker compose -f docker-compose.dev.yml up -d
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redis
|
||||
# ---------------------------------------------------------------------------
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: kener-redis-dev
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kener — built from local Dockerfile
|
||||
# ---------------------------------------------------------------------------
|
||||
kener:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VARIANT: alpine # or "debian"
|
||||
NODE_VERSION: 24
|
||||
WITH_DOCS: "true"
|
||||
KENER_BASE_PATH: ""
|
||||
container_name: kener-dev
|
||||
environment:
|
||||
KENER_SECRET_KEY: dev-secret-key-for-local-testing-only
|
||||
ORIGIN: http://localhost:3000
|
||||
REDIS_URL: redis://redis:6379
|
||||
# DATABASE_URL: sqlite://./database/kener.sqlite.db
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- dev_data:/app/database
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
dev_data:
|
||||
@@ -0,0 +1,47 @@
|
||||
# =============================================================================
|
||||
# Kener v4 — Production Docker Compose for /status base path
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.status.yml up -d
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: kener-redis-status
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
kener:
|
||||
image: rajnandan1/kener:latest-status
|
||||
# For Alpine variant use: rajnandan1/kener:latest-status-alpine
|
||||
container_name: kener-status
|
||||
environment:
|
||||
KENER_SECRET_KEY: replace_me_with_a_random_string # generate: openssl rand -base64 32
|
||||
ORIGIN: http://localhost:3000/status
|
||||
REDIS_URL: redis://redis:6379
|
||||
KENER_BASE_PATH: /status
|
||||
|
||||
# DATABASE_URL: sqlite://./database/kener.sqlite.db
|
||||
# DATABASE_URL: postgresql://user:password@postgres:5432/kener
|
||||
# DATABASE_URL: mysql://user:password@mysql:3306/kener
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- data:/app/database
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
data:
|
||||
name: kener_db_status
|
||||
redis_data:
|
||||
name: kener_redis_status
|
||||
@@ -0,0 +1,115 @@
|
||||
# =============================================================================
|
||||
# Kener v4 — Production Docker Compose
|
||||
#
|
||||
# Usage:
|
||||
# docker compose up -d
|
||||
#
|
||||
# Defaults to the published image. To use a local build instead:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redis — required for BullMQ queues, caching, and scheduler
|
||||
# ---------------------------------------------------------------------------
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: kener-redis
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kener — Status Page Application
|
||||
# ---------------------------------------------------------------------------
|
||||
kener:
|
||||
image: rajnandan1/kener:latest
|
||||
# For Alpine variant use: rajnandan1/kener:alpine
|
||||
container_name: kener
|
||||
environment:
|
||||
# ── Required ──
|
||||
KENER_SECRET_KEY: replace_me_with_a_random_string # generate: openssl rand -base64 32
|
||||
ORIGIN: http://localhost:3000 # public URL of your Kener instance (required for CSRF protection)
|
||||
REDIS_URL: redis://redis:6379
|
||||
|
||||
# ── Database (default: SQLite) ──
|
||||
# DATABASE_URL: sqlite://./database/kener.sqlite.db
|
||||
# DATABASE_URL: postgresql://user:password@postgres:5432/kener
|
||||
# DATABASE_URL: mysql://user:password@mysql:3306/kener
|
||||
|
||||
# ── Email (optional) ──
|
||||
# RESEND_API_KEY:
|
||||
# RESEND_SENDER_EMAIL:
|
||||
# SMTP_HOST:
|
||||
# SMTP_PORT:
|
||||
# SMTP_USER:
|
||||
# SMTP_PASSWORD:
|
||||
# SMTP_SENDER:
|
||||
# SMTP_SECURE: 0
|
||||
|
||||
# ── Advanced (you likely don't need to change these) ──
|
||||
# PORT: 3000
|
||||
# KENER_BASE_PATH:
|
||||
# NODE_ENV: production # already set in the image
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- data:/app/database
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optional: PostgreSQL (uncomment and set DATABASE_URL above)
|
||||
# ---------------------------------------------------------------------------
|
||||
# postgres:
|
||||
# image: postgres:16-alpine
|
||||
# container_name: kener-postgres
|
||||
# environment:
|
||||
# POSTGRES_USER: kener
|
||||
# POSTGRES_PASSWORD: change_me # use a strong password
|
||||
# POSTGRES_DB: kener
|
||||
# volumes:
|
||||
# - postgres_data:/var/lib/postgresql/data
|
||||
# restart: unless-stopped
|
||||
# healthcheck:
|
||||
# test: ["CMD-SHELL", "pg_isready -U kener"]
|
||||
# interval: 10s
|
||||
# timeout: 5s
|
||||
# retries: 5
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optional: MySQL / MariaDB (uncomment and set DATABASE_URL above)
|
||||
# ---------------------------------------------------------------------------
|
||||
# mysql:
|
||||
# image: mariadb:11
|
||||
# container_name: kener-mysql
|
||||
# environment:
|
||||
# MYSQL_USER: kener
|
||||
# MYSQL_PASSWORD: change_me # use a strong password
|
||||
# MYSQL_DATABASE: kener
|
||||
# MYSQL_RANDOM_ROOT_PASSWORD: "true"
|
||||
# volumes:
|
||||
# - mysql_data:/var/lib/mysql
|
||||
# restart: unless-stopped
|
||||
# healthcheck:
|
||||
# test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
# interval: 10s
|
||||
# timeout: 5s
|
||||
# retries: 5
|
||||
|
||||
volumes:
|
||||
data:
|
||||
name: kener_db
|
||||
redis_data:
|
||||
name: kener_redis
|
||||
# postgres_data:
|
||||
# name: kener_postgres
|
||||
# mysql_data:
|
||||
# name: kener_mysql
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/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..."
|
||||
node --experimental-strip-types /app/scripts/index-docs.ts || \
|
||||
echo "[kener] Warning: docs indexing failed (is REDIS_URL set?). Continuing..."
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -1,135 +0,0 @@
|
||||
// import { handler } from "./build/handler.js";
|
||||
import { apiReference } from "@scalar/express-api-reference";
|
||||
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 fs from "fs-extra";
|
||||
import knex from "knex";
|
||||
import knexOb from "./knexfile.js";
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const base = process.env.KENER_BASE_PATH || "";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const app: any = express();
|
||||
const db = knex(knexOb);
|
||||
|
||||
if (process.env.ORIGIN) {
|
||||
process.env.SVELTEKIT_ORIGIN = process.env.ORIGIN;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
app.use((req: any, res: any, next: any) => {
|
||||
if (req.path.startsWith("/embed")) {
|
||||
res.setHeader("Content-Security-Policy", "frame-ancestors *");
|
||||
}
|
||||
res.setHeader("X-Powered-By", "Kener");
|
||||
next();
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
app.get(base + "/healthcheck", (req: any, res: any) => {
|
||||
res.end("ok");
|
||||
});
|
||||
|
||||
//part /uploads server static files from static/uploads
|
||||
|
||||
//set env variable for upload path
|
||||
process.env.UPLOAD_PATH = "./uploads";
|
||||
|
||||
app.use(base + "/uploads", express.static("uploads"));
|
||||
|
||||
try {
|
||||
const openapiJSON = fs.readFileSync("./openapi.json", "utf-8");
|
||||
app.use(
|
||||
"/api-reference",
|
||||
apiReference({
|
||||
spec: {
|
||||
content: openapiJSON,
|
||||
},
|
||||
theme: "alternate",
|
||||
hideModels: true,
|
||||
hideTestRequestButton: true,
|
||||
darkMode: true,
|
||||
metaData: {
|
||||
title: "Kener API Reference",
|
||||
description: "Kener free open source status page API Reference",
|
||||
ogDescription: "Kener free open source status page API Reference",
|
||||
ogTitle: "Kener API Reference",
|
||||
ogImage: "https://kener.ing/newbg.png",
|
||||
twitterCard: "summary_large_image",
|
||||
twitterTitle: "Kener API Reference",
|
||||
twitterDescription: "Kener free open source status page API Reference",
|
||||
twitterImage: "https://kener.ing/newbg.png",
|
||||
},
|
||||
favicon: "https://kener.ing/logo96.png",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any),
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn("Error loading openapi.json, but that is okay.");
|
||||
}
|
||||
|
||||
// app.use(handler);
|
||||
|
||||
//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);
|
||||
}
|
||||
}
|
||||
|
||||
//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"));
|
||||
@@ -1,133 +0,0 @@
|
||||
// migrations/YYYYMMDDHHMMSS_create_monitoring_tables.js
|
||||
|
||||
export function up(knex) {
|
||||
return (
|
||||
knex.schema
|
||||
// Create monitoring_data table
|
||||
.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) => {
|
||||
table.increments("id").primary();
|
||||
table.string("monitor_tag", 255).notNullable();
|
||||
table.string("monitor_status", 255).notNullable();
|
||||
table.string("alert_status", 255).notNullable();
|
||||
table.integer("health_checks").notNullable();
|
||||
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) => {
|
||||
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) => {
|
||||
table.increments("id").primary();
|
||||
table.string("tag", 255).notNullable().unique();
|
||||
table.string("name", 255).notNullable().unique();
|
||||
table.text("description");
|
||||
table.text("image");
|
||||
table.string("cron", 255);
|
||||
table.string("default_status", 255);
|
||||
table.string("status", 255);
|
||||
table.string("category_name", 255);
|
||||
table.string("monitor_type", 255);
|
||||
table.string("down_trigger", 255);
|
||||
table.string("degraded_trigger", 255);
|
||||
table.text("type_data");
|
||||
table.integer("day_degraded_minimum_count");
|
||||
table.integer("day_down_minimum_count");
|
||||
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) => {
|
||||
table.increments("id").primary();
|
||||
table.string("name", 255).notNullable().unique();
|
||||
table.string("trigger_type", 255);
|
||||
table.text("trigger_desc");
|
||||
table.string("trigger_status", 255);
|
||||
table.text("trigger_meta");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
})
|
||||
.createTable("users", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.string("email", 255).notNullable().unique();
|
||||
table.string("name", 255).notNullable();
|
||||
table.string("password_hash", 255).notNullable();
|
||||
table.integer("is_active").defaultTo(1);
|
||||
table.integer("is_verified").defaultTo(0);
|
||||
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) => {
|
||||
table.increments("id").primary();
|
||||
table.string("name", 255).notNullable().unique();
|
||||
table.string("hashed_key", 255).notNullable().unique();
|
||||
table.string("masked_key", 255).notNullable();
|
||||
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) => {
|
||||
table.increments("id").primary();
|
||||
table.string("title", 255).notNullable();
|
||||
table.integer("start_date_time").notNullable();
|
||||
table.integer("end_date_time");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
table.string("status", 255).defaultTo("ACTIVE");
|
||||
table.string("state", 255).defaultTo("INVESTIGATING");
|
||||
})
|
||||
.createTable("incident_monitors", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.string("monitor_tag", 255).notNullable();
|
||||
table.string("monitor_impact", 255);
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
table.integer("incident_id").notNullable();
|
||||
table.unique(["monitor_tag", "incident_id"]);
|
||||
})
|
||||
.createTable("incident_comments", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.text("comment").notNullable();
|
||||
table.integer("incident_id").notNullable();
|
||||
table.integer("commented_at").notNullable();
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
table.string("status", 255).defaultTo("ACTIVE");
|
||||
table.string("state", 255).defaultTo("INVESTIGATING");
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function down(knex) {
|
||||
return (
|
||||
knex.schema
|
||||
// Drop tables in reverse order
|
||||
.dropTableIfExists("monitor_alerts")
|
||||
.dropTableIfExists("monitoring_data")
|
||||
.dropTableIfExists("site_data")
|
||||
.dropTableIfExists("monitors")
|
||||
.dropTableIfExists("triggers")
|
||||
.dropTableIfExists("users")
|
||||
.dropTableIfExists("api_keys")
|
||||
.dropTableIfExists("incidents")
|
||||
.dropTableIfExists("incident_monitors")
|
||||
.dropTableIfExists("incident_comments")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
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"]);
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
table.string("alert_status", 255).notNullable();
|
||||
table.integer("health_checks").notNullable();
|
||||
table.integer("incident_number").defaultTo(0);
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
// 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());
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
table.text("description");
|
||||
table.text("image");
|
||||
table.string("cron", 255);
|
||||
table.string("default_status", 255);
|
||||
table.string("status", 255);
|
||||
table.string("category_name", 255);
|
||||
table.string("monitor_type", 255);
|
||||
table.string("down_trigger", 255);
|
||||
table.string("degraded_trigger", 255);
|
||||
table.text("type_data");
|
||||
table.integer("day_degraded_minimum_count");
|
||||
table.integer("day_down_minimum_count");
|
||||
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());
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
table.text("trigger_desc");
|
||||
table.string("trigger_status", 255);
|
||||
table.text("trigger_meta");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
table.string("password_hash", 255).notNullable();
|
||||
table.integer("is_active").defaultTo(1);
|
||||
table.integer("is_verified").defaultTo(0);
|
||||
table.string("role", 255).defaultTo("user");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
table.string("masked_key", 255).notNullable();
|
||||
table.string("status", 255).defaultTo("ACTIVE");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
table.integer("end_date_time");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
table.string("status", 255).defaultTo("ACTIVE");
|
||||
table.string("state", 255).defaultTo("INVESTIGATING");
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
table.integer("incident_id").notNullable();
|
||||
table.unique(["monitor_tag", "incident_id"]);
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
table.integer("commented_at").notNullable();
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
table.string("status", 255).defaultTo("ACTIVE");
|
||||
table.string("state", 255).defaultTo("INVESTIGATING");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema
|
||||
.dropTableIfExists("monitor_alerts")
|
||||
.dropTableIfExists("monitoring_data")
|
||||
.dropTableIfExists("site_data")
|
||||
.dropTableIfExists("monitors")
|
||||
.dropTableIfExists("triggers")
|
||||
.dropTableIfExists("users")
|
||||
.dropTableIfExists("api_keys")
|
||||
.dropTableIfExists("incidents")
|
||||
.dropTableIfExists("incident_monitors")
|
||||
.dropTableIfExists("incident_comments");
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export function up(knex) {
|
||||
return knex.schema.alterTable("incidents", function (table) {
|
||||
table.text("incident_type").defaultTo("INCIDENT");
|
||||
});
|
||||
}
|
||||
|
||||
export function down(knex) {
|
||||
return knex.schema.alterTable("incidents", function (table) {
|
||||
table.dropColumn("incident_type");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
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> {
|
||||
await knex.schema.alterTable("incidents", function (table) {
|
||||
table.dropColumn("incident_type");
|
||||
});
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export function up(knex) {
|
||||
return knex.schema.alterTable("incidents", function (table) {
|
||||
table.text("incident_source").defaultTo("DASHBOARD");
|
||||
});
|
||||
}
|
||||
|
||||
export function down(knex) {
|
||||
return knex.schema.alterTable("incidents", function (table) {
|
||||
table.dropColumn("incident_source");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
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> {
|
||||
await knex.schema.alterTable("incidents", function (table) {
|
||||
table.dropColumn("incident_source");
|
||||
});
|
||||
}
|
||||
+8
-12
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
export function up(knex) {
|
||||
return knex.schema.createTable("invitations", (table) => {
|
||||
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();
|
||||
|
||||
@@ -29,10 +29,6 @@ export function up(knex) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
export function down(knex) {
|
||||
return knex.schema.dropTableIfExists("invitations");
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists("invitations");
|
||||
}
|
||||
+18
-10
@@ -1,6 +1,8 @@
|
||||
export function up(knex) {
|
||||
return knex.schema
|
||||
.createTable("subscribers", (table) => {
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
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();
|
||||
@@ -14,8 +16,11 @@ export function up(knex) {
|
||||
|
||||
// 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();
|
||||
@@ -30,8 +35,11 @@ export function up(knex) {
|
||||
|
||||
// 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();
|
||||
@@ -39,10 +47,10 @@ export function up(knex) {
|
||||
table.datetime("created_at").defaultTo(knex.fn.now());
|
||||
table.datetime("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function down(knex) {
|
||||
return knex.schema
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema
|
||||
.dropTableIfExists("subscription_triggers")
|
||||
.dropTableIfExists("subscriptions")
|
||||
.dropTableIfExists("subscribers");
|
||||
@@ -1,186 +0,0 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// monitoring_data indexes
|
||||
// For queries: WHERE monitor_tag = ? AND type = ? ORDER BY timestamp DESC (getLastHeartbeat)
|
||||
await knex.schema.alterTable("monitoring_data", (table) => {
|
||||
table.index(["monitor_tag", "type", "timestamp"], "idx_monitoring_data_tag_type_ts");
|
||||
});
|
||||
|
||||
// For background cleanup: WHERE timestamp < ?
|
||||
await knex.schema.alterTable("monitoring_data", (table) => {
|
||||
table.index(["timestamp"], "idx_monitoring_data_timestamp");
|
||||
});
|
||||
|
||||
// monitor_alerts indexes
|
||||
// For queries: WHERE incident_number = ?
|
||||
await knex.schema.alterTable("monitor_alerts", (table) => {
|
||||
table.index(["incident_number"], "idx_monitor_alerts_incident_number");
|
||||
});
|
||||
|
||||
// For queries: WHERE monitor_tag = ? AND monitor_status = ? AND alert_status = ?
|
||||
await knex.schema.alterTable("monitor_alerts", (table) => {
|
||||
table.index(["monitor_tag", "monitor_status", "alert_status"], "idx_monitor_alerts_tag_status_alert");
|
||||
});
|
||||
|
||||
// monitors indexes
|
||||
// For queries: WHERE status = ?
|
||||
await knex.schema.alterTable("monitors", (table) => {
|
||||
table.index(["status"], "idx_monitors_status");
|
||||
});
|
||||
|
||||
// For queries: WHERE category_name = ?
|
||||
await knex.schema.alterTable("monitors", (table) => {
|
||||
table.index(["category_name"], "idx_monitors_category");
|
||||
});
|
||||
|
||||
// incidents indexes
|
||||
// For queries: WHERE status = ? ORDER BY start_date_time
|
||||
await knex.schema.alterTable("incidents", (table) => {
|
||||
table.index(["status", "start_date_time"], "idx_incidents_status_start");
|
||||
});
|
||||
|
||||
// For queries: WHERE start_date_time >= ? AND start_date_time <= ?
|
||||
await knex.schema.alterTable("incidents", (table) => {
|
||||
table.index(["start_date_time", "end_date_time"], "idx_incidents_start_end");
|
||||
});
|
||||
|
||||
// For queries: ORDER BY updated_at DESC (getRecentUpdatedIncidents)
|
||||
await knex.schema.alterTable("incidents", (table) => {
|
||||
table.index(["updated_at"], "idx_incidents_updated_at");
|
||||
});
|
||||
|
||||
// For queries: WHERE state = ?
|
||||
await knex.schema.alterTable("incidents", (table) => {
|
||||
table.index(["state"], "idx_incidents_state");
|
||||
});
|
||||
|
||||
// For queries: WHERE incident_type = ?
|
||||
await knex.schema.alterTable("incidents", (table) => {
|
||||
table.index(["incident_type"], "idx_incidents_type");
|
||||
});
|
||||
|
||||
// incident_monitors indexes
|
||||
// For queries: WHERE incident_id = ? (joining with incidents)
|
||||
await knex.schema.alterTable("incident_monitors", (table) => {
|
||||
table.index(["incident_id"], "idx_incident_monitors_incident_id");
|
||||
});
|
||||
|
||||
// For queries: WHERE monitor_tag = ? (getIncidentsByMonitorTag)
|
||||
await knex.schema.alterTable("incident_monitors", (table) => {
|
||||
table.index(["monitor_tag"], "idx_incident_monitors_monitor_tag");
|
||||
});
|
||||
|
||||
// incident_comments indexes
|
||||
// For queries: WHERE incident_id = ?
|
||||
await knex.schema.alterTable("incident_comments", (table) => {
|
||||
table.index(["incident_id"], "idx_incident_comments_incident_id");
|
||||
});
|
||||
|
||||
// For queries: WHERE incident_id = ? AND status = ?
|
||||
await knex.schema.alterTable("incident_comments", (table) => {
|
||||
table.index(["incident_id", "status"], "idx_incident_comments_incident_status");
|
||||
});
|
||||
|
||||
// triggers indexes
|
||||
// For queries: WHERE trigger_status = ?
|
||||
await knex.schema.alterTable("triggers", (table) => {
|
||||
table.index(["trigger_status"], "idx_triggers_status");
|
||||
});
|
||||
|
||||
// subscribers indexes
|
||||
// For queries: WHERE subscriber_status = ?
|
||||
await knex.schema.alterTable("subscribers", (table) => {
|
||||
table.index(["subscriber_status"], "idx_subscribers_status");
|
||||
});
|
||||
|
||||
// For queries: WHERE subscriber_type = ?
|
||||
await knex.schema.alterTable("subscribers", (table) => {
|
||||
table.index(["subscriber_type"], "idx_subscribers_type");
|
||||
});
|
||||
|
||||
// invitations indexes
|
||||
// For queries: WHERE invited_user_id = ? AND invitation_type = ? AND invitation_status = ?
|
||||
await knex.schema.alterTable("invitations", (table) => {
|
||||
table.index(["invited_user_id", "invitation_type", "invitation_status"], "idx_invitations_user_type_status");
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
// Drop indexes in reverse order
|
||||
await knex.schema.alterTable("invitations", (table) => {
|
||||
table.dropIndex([], "idx_invitations_user_type_status");
|
||||
});
|
||||
|
||||
await knex.schema.alterTable("subscribers", (table) => {
|
||||
table.dropIndex([], "idx_subscribers_type");
|
||||
});
|
||||
|
||||
await knex.schema.alterTable("subscribers", (table) => {
|
||||
table.dropIndex([], "idx_subscribers_status");
|
||||
});
|
||||
|
||||
await knex.schema.alterTable("triggers", (table) => {
|
||||
table.dropIndex([], "idx_triggers_status");
|
||||
});
|
||||
|
||||
await knex.schema.alterTable("incident_comments", (table) => {
|
||||
table.dropIndex([], "idx_incident_comments_incident_status");
|
||||
});
|
||||
|
||||
await knex.schema.alterTable("incident_comments", (table) => {
|
||||
table.dropIndex([], "idx_incident_comments_incident_id");
|
||||
});
|
||||
|
||||
await knex.schema.alterTable("incident_monitors", (table) => {
|
||||
table.dropIndex([], "idx_incident_monitors_monitor_tag");
|
||||
});
|
||||
|
||||
await knex.schema.alterTable("incident_monitors", (table) => {
|
||||
table.dropIndex([], "idx_incident_monitors_incident_id");
|
||||
});
|
||||
|
||||
await knex.schema.alterTable("incidents", (table) => {
|
||||
table.dropIndex([], "idx_incidents_type");
|
||||
});
|
||||
|
||||
await knex.schema.alterTable("incidents", (table) => {
|
||||
table.dropIndex([], "idx_incidents_state");
|
||||
});
|
||||
|
||||
await knex.schema.alterTable("incidents", (table) => {
|
||||
table.dropIndex([], "idx_incidents_updated_at");
|
||||
});
|
||||
|
||||
await knex.schema.alterTable("incidents", (table) => {
|
||||
table.dropIndex([], "idx_incidents_start_end");
|
||||
});
|
||||
|
||||
await knex.schema.alterTable("incidents", (table) => {
|
||||
table.dropIndex([], "idx_incidents_status_start");
|
||||
});
|
||||
|
||||
await knex.schema.alterTable("monitors", (table) => {
|
||||
table.dropIndex([], "idx_monitors_category");
|
||||
});
|
||||
|
||||
await knex.schema.alterTable("monitors", (table) => {
|
||||
table.dropIndex([], "idx_monitors_status");
|
||||
});
|
||||
|
||||
await knex.schema.alterTable("monitor_alerts", (table) => {
|
||||
table.dropIndex([], "idx_monitor_alerts_tag_status_alert");
|
||||
});
|
||||
|
||||
await knex.schema.alterTable("monitor_alerts", (table) => {
|
||||
table.dropIndex([], "idx_monitor_alerts_incident_number");
|
||||
});
|
||||
|
||||
await knex.schema.alterTable("monitoring_data", (table) => {
|
||||
table.dropIndex([], "idx_monitoring_data_timestamp");
|
||||
});
|
||||
|
||||
await knex.schema.alterTable("monitoring_data", (table) => {
|
||||
table.dropIndex([], "idx_monitoring_data_tag_type_ts");
|
||||
});
|
||||
}
|
||||
@@ -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,18 +0,0 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// Create email_templates_config table
|
||||
await knex.schema.createTable("email_templates_config", (table) => {
|
||||
table.string("email_type", 50).notNullable().primary(); // email_code, forgot_password, verify_email
|
||||
//template_subject
|
||||
table.text("template_subject").notNullable();
|
||||
//template_html_body
|
||||
table.text("template_html_body").notNullable();
|
||||
//template_text_body
|
||||
table.text("template_text_body").notNullable();
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists("email_templates_config");
|
||||
}
|
||||
@@ -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,49 +0,0 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists("subscription_triggers");
|
||||
await knex.schema.dropTableIfExists("subscriptions");
|
||||
await knex.schema.dropTableIfExists("subscribers");
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema
|
||||
.createTable("subscribers", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.string("subscriber_send").notNullable();
|
||||
table.text("subscriber_meta").nullable();
|
||||
table.string("subscriber_type").notNullable();
|
||||
table.string("subscriber_status").notNullable();
|
||||
table.dateTime("created_at").defaultTo(knex.fn.now());
|
||||
table.dateTime("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
// Add unique constraint on subscriber_send and subscriber_type
|
||||
table.unique(["subscriber_send", "subscriber_type"]);
|
||||
|
||||
// Add index on subscriber_send for better query performance
|
||||
table.index(["subscriber_send"]);
|
||||
})
|
||||
.createTable("subscriptions", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.integer("subscriber_id").unsigned().notNullable();
|
||||
table.string("subscriptions_status").notNullable();
|
||||
table.string("subscriptions_monitors").notNullable();
|
||||
table.text("subscriptions_meta").nullable();
|
||||
table.dateTime("created_at").defaultTo(knex.fn.now());
|
||||
table.dateTime("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
// Add unique constraint on subscriber_id and subscriptions_monitors
|
||||
table.unique(["subscriber_id", "subscriptions_monitors"]);
|
||||
|
||||
// Add index to optimize queries filtering by status and monitors
|
||||
table.index(["subscriptions_status", "subscriptions_monitors"]);
|
||||
})
|
||||
.createTable("subscription_triggers", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.string("subscription_trigger_type").notNullable().unique();
|
||||
table.string("subscription_trigger_status").notNullable();
|
||||
table.text("config").nullable();
|
||||
table.dateTime("created_at").defaultTo(knex.fn.now());
|
||||
table.dateTime("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
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> {
|
||||
await knex.schema.alterTable("monitoring_data", (table) => {
|
||||
table.dropIndex(["timestamp"], "idx_monitoring_data_timestamp");
|
||||
table.dropIndex(["monitor_tag", "type", "timestamp"], "idx_monitoring_data_monitor_tag_type_timestamp");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
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> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Generated
+3805
-6249
File diff suppressed because it is too large
Load Diff
+30
-20
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kener",
|
||||
"version": "4.0.0",
|
||||
"version": "4.0.21",
|
||||
"type": "module",
|
||||
"private": false,
|
||||
"license": "MIT",
|
||||
@@ -28,7 +28,10 @@
|
||||
"url": "https://github.com/rajnandan1/kener.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"build": "node scripts/build-sveltekit.js && node scripts/build-server.js",
|
||||
"build-with-docs": "node scripts/build-sveltekit.js --with-docs && node scripts/build-server.js",
|
||||
"build:sveltekit": "vite build",
|
||||
"build:server": "node scripts/build-server.js",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"configure": "node build.js",
|
||||
@@ -36,22 +39,23 @@
|
||||
"development": "vite dev",
|
||||
"devschedule": "vite-node src/lib/server/startup.ts",
|
||||
"generate-readme": "node scripts/generate-readme.js",
|
||||
"migrate": "npx knex migrate:latest",
|
||||
"index-docs": "vite-node scripts/index-docs.ts",
|
||||
"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",
|
||||
"seed": "npx knex seed:run",
|
||||
"start": "npx tsx main.ts"
|
||||
"start": "node build/main.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.10.0",
|
||||
"@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",
|
||||
@@ -60,12 +64,11 @@
|
||||
"@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",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"@zerodevx/svelte-toast": "^0.9.6",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"clsx": "^2.1.1",
|
||||
"concurrently": "^9.2.1",
|
||||
@@ -77,16 +80,16 @@
|
||||
"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-dnd-action": "^0.9.68",
|
||||
"svelte-sonner": "^1.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vaul-svelte": "^1.0.0-next.7",
|
||||
"vite": "^7.2.2",
|
||||
"vite-node": "^5.3.0",
|
||||
"vite-plugin-devtools-json": "^1.0.0"
|
||||
@@ -110,14 +113,14 @@
|
||||
"@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",
|
||||
"@openai/agents": "^0.3.7",
|
||||
"@rajnandan1/aven": "file:../aven",
|
||||
"@rajnandan1/svelte-legos": "^0.0.1",
|
||||
"@scalar/express-api-reference": "^0.8.28",
|
||||
"@scalar/sveltekit": "^0.1.43",
|
||||
"@scaleway/random-name": "^5.1.4",
|
||||
"@uiw/codemirror-theme-github": "^4.25.3",
|
||||
"analytics": "^0.8.19",
|
||||
"axios": "^1.13.2",
|
||||
"badge-maker": "^5.0.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
@@ -135,8 +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",
|
||||
@@ -151,8 +155,6 @@
|
||||
"marked-plaintify": "^1.1.1",
|
||||
"mobile-detect": "^1.4.5",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.6.0",
|
||||
"mustache": "^4.2.0",
|
||||
"mysql2": "^3.15.3",
|
||||
"nanoid": "^5.1.6",
|
||||
@@ -162,7 +164,6 @@
|
||||
"pg": "^8.16.3",
|
||||
"pg-pool": "^3.10.1",
|
||||
"ping": "^1.0.0",
|
||||
"queue": "^7.0.0",
|
||||
"randomstring": "^1.3.1",
|
||||
"resend": "^6.6.0",
|
||||
"rrule": "^2.8.1",
|
||||
@@ -170,7 +171,16 @@
|
||||
"striptags": "^3.2.0",
|
||||
"style-to-object": "^1.0.14",
|
||||
"svelte-codemirror-editor": "^2.1.0",
|
||||
"vite-plugin-package-version": "^1.1.0",
|
||||
"zod": "^4.3.2"
|
||||
"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
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as esbuild from "esbuild";
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
const pkg = JSON.parse(readFileSync("./package.json", "utf8"));
|
||||
|
||||
// Collect all dependency names to externalize, except CJS packages
|
||||
// that need to be bundled for ESM compatibility
|
||||
const CJS_PACKAGES_TO_BUNDLE = ["rrule"];
|
||||
|
||||
const externalDeps = [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.devDependencies || {})].filter(
|
||||
(dep) => !CJS_PACKAGES_TO_BUNDLE.includes(dep),
|
||||
);
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: ["scripts/main.ts"],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
target: "node20",
|
||||
format: "esm",
|
||||
outfile: "build/main.js",
|
||||
// Externalize all node_modules except CJS packages that break ESM named imports
|
||||
external: externalDeps,
|
||||
alias: {
|
||||
// Map SvelteKit's $lib alias so server code resolves correctly
|
||||
$lib: "./src/lib",
|
||||
},
|
||||
define: {
|
||||
// Inject version at build time so src/lib/version.ts resolves it
|
||||
// without relying on vite-plugin-package-version at runtime
|
||||
"import.meta.env.PACKAGE_VERSION": JSON.stringify(pkg.version),
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: "rewrite-build-imports",
|
||||
setup(build) {
|
||||
// Since the output lives in build/, rewrite ../build/X → ./X
|
||||
// so that handler.js (SvelteKit output) resolves correctly
|
||||
build.onResolve({ filter: /^\.\.\/build\// }, (args) => {
|
||||
return {
|
||||
path: args.path.replace(/^\.\.\/build\//, "./"),
|
||||
external: true,
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
banner: {
|
||||
js: "// Kener production server – built with esbuild",
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Server build completed: build/main.js (v${pkg.version})`);
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* SvelteKit build script that optionally excludes docs routes.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-sveltekit.js # build WITHOUT docs
|
||||
* node scripts/build-sveltekit.js --with-docs # build WITH docs
|
||||
*/
|
||||
import { execSync } from "child_process";
|
||||
import { renameSync, existsSync, rmSync } from "fs";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = resolve(__dirname, "..");
|
||||
|
||||
const withDocs = process.argv.includes("--with-docs");
|
||||
|
||||
const docsDir = resolve(rootDir, "src/routes/(docs)");
|
||||
const docsHiddenDir = resolve(rootDir, ".docs-excluded");
|
||||
|
||||
function moveDocs(from, to) {
|
||||
if (existsSync(from)) {
|
||||
renameSync(from, to);
|
||||
}
|
||||
}
|
||||
|
||||
function build() {
|
||||
if (!withDocs) {
|
||||
console.log("[build] Excluding docs routes from build...");
|
||||
moveDocs(docsDir, docsHiddenDir);
|
||||
// Clean generated route types so stale docs routes don't persist
|
||||
const svelteKitDir = resolve(rootDir, ".svelte-kit");
|
||||
if (existsSync(svelteKitDir)) {
|
||||
rmSync(svelteKitDir, { recursive: true, force: true });
|
||||
}
|
||||
} else {
|
||||
console.log("[build] Including docs routes in build...");
|
||||
}
|
||||
|
||||
try {
|
||||
execSync("npx vite build", { cwd: rootDir, stdio: "inherit" });
|
||||
} finally {
|
||||
// Always restore docs folder, even if build fails
|
||||
if (!withDocs) {
|
||||
moveDocs(docsHiddenDir, docsDir);
|
||||
console.log("[build] Restored docs routes.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
build();
|
||||
@@ -0,0 +1,210 @@
|
||||
//node scripts/check-translations.js
|
||||
|
||||
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 srcDir = path.join(projectRoot, "src");
|
||||
const localesDir = path.join(srcDir, "lib", "locales");
|
||||
|
||||
const SUPPORTED_FORMATS = new Set(["json", "yaml"]);
|
||||
|
||||
const SOURCE_GLOBS = ["src/**/*.{svelte,ts,js,mts,cts}", "!src/lib/locales/**/*.json", "!src/**/*.d.ts"];
|
||||
|
||||
const TRANSLATION_CALL_REGEX = /\$t\s*\(\s*(["'`])([\s\S]*?)\1\s*(?:,|\))/g;
|
||||
|
||||
const COMMENT_PATTERNS = [/\/\*[\s\S]*?\*\//g, /\/\/[^\n\r]*/g, /<!--[\s\S]*?-->/g];
|
||||
|
||||
const WHITELISTED_DYNAMIC_KEYS = new Set([
|
||||
"All Systems Operational",
|
||||
"Degraded Performance",
|
||||
"Partial Degraded Performance",
|
||||
"Partial System Outage",
|
||||
"Major System Outage",
|
||||
"No Status Available",
|
||||
]);
|
||||
|
||||
function decodeQuotedContent(value) {
|
||||
return value
|
||||
.replace(/\\\\/g, "\\")
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\r/g, "\r")
|
||||
.replace(/\\t/g, "\t")
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\'/g, "'")
|
||||
.replace(/\\`/g, "`");
|
||||
}
|
||||
|
||||
function getLineNumber(text, index) {
|
||||
let line = 1;
|
||||
for (let i = 0; i < index; i += 1) {
|
||||
if (text[i] === "\n") {
|
||||
line += 1;
|
||||
}
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function maskComments(content) {
|
||||
let result = content;
|
||||
for (const pattern of COMMENT_PATTERNS) {
|
||||
result = result.replace(pattern, (match) => match.replace(/[^\n]/g, " "));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getUsedTranslationKeys() {
|
||||
const files = globSync(SOURCE_GLOBS, {
|
||||
cwd: projectRoot,
|
||||
nodir: true,
|
||||
ignore: ["**/node_modules/**", "**/.svelte-kit/**", "**/build/**", "**/dist/**"],
|
||||
});
|
||||
|
||||
const usedKeys = new Set();
|
||||
const skippedDynamicCalls = [];
|
||||
|
||||
for (const relativePath of files) {
|
||||
const absolutePath = path.join(projectRoot, relativePath);
|
||||
const content = fs.readFileSync(absolutePath, "utf8");
|
||||
const searchable = maskComments(content);
|
||||
TRANSLATION_CALL_REGEX.lastIndex = 0;
|
||||
|
||||
for (const match of searchable.matchAll(TRANSLATION_CALL_REGEX)) {
|
||||
const quote = match[1];
|
||||
const rawKey = match[2].trim();
|
||||
if (!rawKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote === "`" && rawKey.includes("${")) {
|
||||
skippedDynamicCalls.push({
|
||||
file: relativePath,
|
||||
line: getLineNumber(searchable, match.index ?? 0),
|
||||
reason: "template literal contains interpolation",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
usedKeys.add(decodeQuotedContent(rawKey));
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of WHITELISTED_DYNAMIC_KEYS) {
|
||||
usedKeys.add(key);
|
||||
}
|
||||
|
||||
return {
|
||||
usedKeys,
|
||||
skippedDynamicCalls,
|
||||
};
|
||||
}
|
||||
|
||||
function readLocaleMappings() {
|
||||
const localeFiles = globSync("*.json", {
|
||||
cwd: localesDir,
|
||||
nodir: true,
|
||||
});
|
||||
|
||||
const localeMap = new Map();
|
||||
|
||||
for (const fileName of localeFiles) {
|
||||
const fullPath = path.join(localesDir, fileName);
|
||||
const raw = fs.readFileSync(fullPath, "utf8");
|
||||
const json = JSON.parse(raw);
|
||||
|
||||
if (!json || typeof json !== "object" || typeof json.mappings !== "object") {
|
||||
throw new Error(`Invalid locale file format in ${fileName}: expected { mappings: {...} }`);
|
||||
}
|
||||
|
||||
localeMap.set(fileName, new Set(Object.keys(json.mappings)));
|
||||
}
|
||||
|
||||
return localeMap;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
let format = "json";
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--format") {
|
||||
format = (argv[i + 1] || "").toLowerCase();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("--format=")) {
|
||||
format = arg.split("=")[1].toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
if (!SUPPORTED_FORMATS.has(format)) {
|
||||
throw new Error(`Unsupported format: ${format}. Use --format json or --format yaml.`);
|
||||
}
|
||||
|
||||
return {
|
||||
format,
|
||||
outputFile: path.join(projectRoot, `translation-report.${format === "yaml" ? "yaml" : "json"}`),
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { format, outputFile } = parseArgs(process.argv.slice(2));
|
||||
const { usedKeys, skippedDynamicCalls } = getUsedTranslationKeys();
|
||||
const locales = readLocaleMappings();
|
||||
|
||||
const sortedUsedKeys = [...usedKeys].sort((a, b) => a.localeCompare(b));
|
||||
const localeFiles = [...locales.keys()].sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const localesReport = {};
|
||||
|
||||
for (const localeFile of localeFiles) {
|
||||
const localeKeys = locales.get(localeFile) ?? new Set();
|
||||
|
||||
const missing = sortedUsedKeys.filter((key) => !localeKeys.has(key));
|
||||
const unused = [...localeKeys].filter((key) => !usedKeys.has(key)).sort((a, b) => a.localeCompare(b));
|
||||
|
||||
localesReport[localeFile] = {
|
||||
missing,
|
||||
unused,
|
||||
missingCount: missing.length,
|
||||
unusedCount: unused.length,
|
||||
};
|
||||
}
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
format,
|
||||
scannedSourceDir: "src",
|
||||
usedLiteralKeysCount: sortedUsedKeys.length,
|
||||
usedLiteralKeys: sortedUsedKeys,
|
||||
whitelistedDynamicKeysCount: WHITELISTED_DYNAMIC_KEYS.size,
|
||||
whitelistedDynamicKeys: [...WHITELISTED_DYNAMIC_KEYS].sort((a, b) => a.localeCompare(b)),
|
||||
localeFilesCount: localeFiles.length,
|
||||
locales: localesReport,
|
||||
skippedDynamicCallsCount: skippedDynamicCalls.length,
|
||||
skippedDynamicCalls,
|
||||
};
|
||||
|
||||
if (format === "yaml") {
|
||||
fs.writeFileSync(outputFile, yaml.dump(report, { noRefs: true }) + "\n", "utf8");
|
||||
} else {
|
||||
fs.writeFileSync(outputFile, JSON.stringify(report, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
console.log(`Translation report written to: ${outputFile}`);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error("Failed to check translations.");
|
||||
console.error(error instanceof Error ? error.message : 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();
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Standalone script to index documentation content into Redis for full-text search.
|
||||
*
|
||||
* This script reads docs.json and all markdown files from disk, converts them
|
||||
* to plain text, and stores the search documents in Redis. It should be run
|
||||
* manually whenever documentation content changes.
|
||||
*
|
||||
* Usage: npm run index-docs
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import fm from "front-matter";
|
||||
import IORedis from "ioredis";
|
||||
import dotenv from "dotenv";
|
||||
import { marked } from "marked";
|
||||
import plaintify from "marked-plaintify";
|
||||
import { mdToText } from "../src/lib/marked.ts";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const DOCS_JSON_PATH = path.join(__dirname, "../src/routes/(docs)/docs.json");
|
||||
const CONTENT_DIR = path.join(__dirname, "../src/routes/(docs)/docs/content");
|
||||
const REDIS_DOCS_KEY = "kener-docs:search:documents";
|
||||
|
||||
interface DocsPage {
|
||||
title: string;
|
||||
content?: string;
|
||||
slug?: string;
|
||||
pages?: DocsPageSource[];
|
||||
}
|
||||
|
||||
interface DocsPageSource {
|
||||
title: string;
|
||||
slug: string;
|
||||
pages?: DocsPageSource[];
|
||||
}
|
||||
|
||||
interface DocsSidebarGroup {
|
||||
group: string;
|
||||
pages: DocsPage[];
|
||||
}
|
||||
|
||||
interface DocsNavTab {
|
||||
name: string;
|
||||
url?: string;
|
||||
sidebar?: DocsSidebarGroup[];
|
||||
}
|
||||
|
||||
interface DocsVersion {
|
||||
name: string;
|
||||
slug: string;
|
||||
latest?: boolean;
|
||||
content: {
|
||||
navigation?: {
|
||||
tabs?: DocsNavTab[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface DocsRootConfig {
|
||||
versions: DocsVersion[];
|
||||
}
|
||||
|
||||
interface DocsSearchDocument {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
group: string;
|
||||
content: string;
|
||||
rawContent: string;
|
||||
}
|
||||
|
||||
interface DocsSearchIndexData {
|
||||
documents: DocsSearchDocument[];
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read markdown content for a slug (body only, without frontmatter)
|
||||
*/
|
||||
function getMarkdownContent(slug: string): string | null {
|
||||
const directPath = path.join(CONTENT_DIR, `${slug}.md`);
|
||||
|
||||
try {
|
||||
let rawContent: string | null = null;
|
||||
|
||||
if (fs.existsSync(directPath)) {
|
||||
rawContent = fs.readFileSync(directPath, "utf-8");
|
||||
} else {
|
||||
const indexPath = path.join(CONTENT_DIR, slug, "index.md");
|
||||
if (fs.existsSync(indexPath)) {
|
||||
rawContent = fs.readFileSync(indexPath, "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
if (!rawContent) return null;
|
||||
|
||||
const parsed = fm(rawContent);
|
||||
return parsed.body;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect all pages from sidebar groups (including nested pages)
|
||||
*/
|
||||
function collectPages(
|
||||
pages: DocsPageSource[],
|
||||
group: string,
|
||||
result: Array<{ page: DocsPageSource; group: string }>,
|
||||
): void {
|
||||
for (const page of pages) {
|
||||
result.push({ page, group });
|
||||
if (page.pages && page.pages.length > 0) {
|
||||
collectPages(page.pages, group, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePage(page: DocsPage): DocsPageSource {
|
||||
const resolvedPath = page.content ?? page.slug;
|
||||
|
||||
if (!resolvedPath) {
|
||||
throw new Error(`[index-docs] Page \"${page.title}\" must define content or slug`);
|
||||
}
|
||||
|
||||
return {
|
||||
title: page.title,
|
||||
slug: resolvedPath,
|
||||
pages: page.pages?.map(normalizePage),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSidebar(sidebar: DocsSidebarGroup[]): Array<{ group: string; pages: DocsPageSource[] }> {
|
||||
return sidebar.map((group) => ({
|
||||
group: group.group,
|
||||
pages: group.pages.map(normalizePage),
|
||||
}));
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// Validate Redis URL
|
||||
if (!process.env.REDIS_URL) {
|
||||
console.error("Error: REDIS_URL environment variable is not set.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read docs.json
|
||||
if (!fs.existsSync(DOCS_JSON_PATH)) {
|
||||
console.error(`Error: docs.json not found at ${DOCS_JSON_PATH}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config: DocsRootConfig = JSON.parse(fs.readFileSync(DOCS_JSON_PATH, "utf-8"));
|
||||
const latestVersion = config.versions.find((version) => version.latest) ?? config.versions[0];
|
||||
|
||||
if (!latestVersion) {
|
||||
console.error("[index-docs] No versions found in docs.json");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tabs = latestVersion.content.navigation?.tabs ?? [];
|
||||
const documents: DocsSearchDocument[] = [];
|
||||
|
||||
// Collect all pages from all tabs' sidebars
|
||||
const allPages: Array<{ page: DocsPageSource; group: string }> = [];
|
||||
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}`);
|
||||
console.log(`[index-docs] Found ${allPages.length} pages to index`);
|
||||
|
||||
for (const { page, group } of allPages) {
|
||||
const markdownContent = getMarkdownContent(page.slug);
|
||||
if (markdownContent) {
|
||||
const plainContent = mdToText(markdownContent);
|
||||
documents.push({
|
||||
id: page.slug,
|
||||
title: page.title,
|
||||
slug: page.slug,
|
||||
group,
|
||||
content: plainContent,
|
||||
rawContent: markdownContent,
|
||||
});
|
||||
} else {
|
||||
console.warn(`[index-docs] No content found for slug: ${page.slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[index-docs] Indexed ${documents.length} documents`);
|
||||
|
||||
// Store in Redis
|
||||
const redis = new IORedis(process.env.REDIS_URL, { maxRetriesPerRequest: null });
|
||||
|
||||
const indexData: DocsSearchIndexData = {
|
||||
documents,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
|
||||
await redis.set(REDIS_DOCS_KEY, JSON.stringify(indexData));
|
||||
console.log(`[index-docs] Stored ${documents.length} documents in Redis (key: ${REDIS_DOCS_KEY})`);
|
||||
|
||||
await redis.quit();
|
||||
console.log("[index-docs] Done.");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[index-docs] Fatal error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
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 knex from "knex";
|
||||
import knexOb from "../knexfile.js";
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const base = process.env.KENER_BASE_PATH || "";
|
||||
|
||||
async function start() {
|
||||
// Dynamic import so BODY_SIZE_LIMIT from .env is available
|
||||
// before the handler reads it at module top-level
|
||||
const { handler } = await import("../build/handler.js");
|
||||
|
||||
const app: any = express();
|
||||
const db = knex(knexOb);
|
||||
|
||||
app.get(base + "/healthcheck", (req: any, res: any) => {
|
||||
res.end("ok");
|
||||
});
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
start();
|
||||
@@ -0,0 +1,113 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { globSync } from "glob";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const localesDir = path.join(projectRoot, "src", "lib", "locales");
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
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 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 sortTranslations() {
|
||||
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 changedFilesCount = 0;
|
||||
|
||||
for (const localeFileName of localeFiles) {
|
||||
const localePath = path.join(localesDir, localeFileName);
|
||||
const localeData = loadLocaleJson(localePath, localeFileName);
|
||||
|
||||
const sortedMappings = sortObjectKeysAscending(localeData.mappings);
|
||||
const nextLocaleData = replaceMappingsPreserveTopLevelOrder(localeData, sortedMappings);
|
||||
|
||||
const nextContent = `${JSON.stringify(nextLocaleData, null, 2)}\n`;
|
||||
const currentContent = fs.readFileSync(localePath, "utf8");
|
||||
|
||||
if (nextContent !== currentContent) {
|
||||
fs.writeFileSync(localePath, nextContent, "utf8");
|
||||
changedFilesCount += 1;
|
||||
console.log(`${localeFileName}: sorted mappings in ascending key order`);
|
||||
} else {
|
||||
console.log(`${localeFileName}: already sorted`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Done. Updated ${changedFilesCount} file${changedFilesCount === 1 ? "" : "s"}.`);
|
||||
}
|
||||
|
||||
try {
|
||||
sortTranslations();
|
||||
} catch (error) {
|
||||
console.error("Failed to sort translations.");
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import subscriptionAccountCodeTemplate from "../src/lib/server/templates/general
|
||||
import subscriptionUpdateTemplate from "../src/lib/server/templates/general/subscription_update_template.ts";
|
||||
import forgotPasswordTemplate from "../src/lib/server/templates/general/forgot_password_template.ts";
|
||||
import inviteUserTemplate from "../src/lib/server/templates/general/invite_user_template.ts";
|
||||
import verifyEmailTemplate from "../src/lib/server/templates/general/verify_email_template.ts";
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function seed(knex: Knex): Promise<void> {
|
||||
@@ -58,4 +59,18 @@ export async function seed(knex: Knex): Promise<void> {
|
||||
template_text_body: inviteUserTemplate.template_text_body,
|
||||
});
|
||||
}
|
||||
|
||||
count = await knex("general_email_templates")
|
||||
.where({ template_id: verifyEmailTemplate.template_id })
|
||||
.count("template_id as CNT")
|
||||
.first();
|
||||
|
||||
if (count && count.CNT == 0) {
|
||||
await knex("general_email_templates").insert({
|
||||
template_id: verifyEmailTemplate.template_id,
|
||||
template_subject: verifyEmailTemplate.template_subject,
|
||||
template_html_body: verifyEmailTemplate.template_html_body,
|
||||
template_text_body: verifyEmailTemplate.template_text_body,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -22,7 +22,7 @@ export async function seed(knex: Knex): Promise<void> {
|
||||
.returning("id");
|
||||
|
||||
// For the home page, add the default monitor (earth) if it exists
|
||||
if (page.page_path === "/") {
|
||||
if (page.page_path === "") {
|
||||
const earthMonitor = await knex("monitors").where({ tag: "earth" }).first();
|
||||
if (earthMonitor) {
|
||||
const pageId = typeof insertedPage === "object" ? insertedPage.id : insertedPage;
|
||||
@@ -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,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">
|
||||
|
||||
+44
-14
@@ -1,29 +1,26 @@
|
||||
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 { initializeSearchIndex } from "$lib/server/docs-search";
|
||||
import { GetMonitorsParsed } from "$lib/server/controllers/monitorsController";
|
||||
|
||||
// Initialize documentation search index at server startup
|
||||
initializeSearchIndex();
|
||||
|
||||
const API_PATH_PREFIX = "/api/";
|
||||
|
||||
// Paths that don't require authentication
|
||||
const PUBLIC_API_PATHS = ["/api/status", "/api/spec"];
|
||||
const PUBLIC_API_PATHS = ["/api/status"];
|
||||
|
||||
// Regex to match routes with monitor_tag parameter
|
||||
const MONITOR_TAG_ROUTE_REGEX = /^\/api\/monitors\/([^/]+)/;
|
||||
const MONITOR_TAG_ROUTE_REGEX = /^\/api\/(?:v\d+\/)?monitors\/([^/]+)/;
|
||||
|
||||
// Regex to match routes with incident_id parameter
|
||||
const INCIDENT_ID_ROUTE_REGEX = /^\/api\/incidents\/(\d+)/;
|
||||
const INCIDENT_ID_ROUTE_REGEX = /^\/api\/(?:v\d+\/)?incidents\/(\d+)/;
|
||||
|
||||
// Regex to match routes with maintenance_id parameter
|
||||
const MAINTENANCE_ID_ROUTE_REGEX = /^\/api\/maintenances\/(\d+)/;
|
||||
const MAINTENANCE_ID_ROUTE_REGEX = /^\/api\/(?:v\d+\/)?maintenances\/(\d+)/;
|
||||
|
||||
// Regex to match routes with page_path parameter
|
||||
const PAGE_PATH_ROUTE_REGEX = /^\/api\/pages\/([^/]+)/;
|
||||
const PAGE_PATH_ROUTE_REGEX = /^\/api\/(?:v\d+\/)?pages\/([^/]+)/;
|
||||
|
||||
function isApiRoute(pathname: string): boolean {
|
||||
return pathname.startsWith(API_PATH_PREFIX);
|
||||
@@ -62,7 +59,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
|
||||
@@ -91,7 +119,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
return json(errorResponse, { status: 401 });
|
||||
}
|
||||
|
||||
// Validate monitor tag exists for /api/monitors/:monitor_tag/* routes
|
||||
// Validate monitor tag exists for /api/(vX/)?monitors/:monitor_tag/* routes
|
||||
const monitorTag = extractMonitorTag(pathname);
|
||||
if (monitorTag) {
|
||||
const monitor = await GetMonitorsParsed({ tag: monitorTag }).then((monitors) => monitors[0]);
|
||||
@@ -108,7 +136,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.monitor = monitor;
|
||||
}
|
||||
|
||||
// Validate incident_id exists for /api/incidents/:incident_id/* routes
|
||||
// Validate incident_id exists for /api/(vX/)?incidents/:incident_id/* routes
|
||||
const incidentId = extractIncidentId(pathname);
|
||||
if (incidentId) {
|
||||
const incident = await db.getIncidentById(incidentId);
|
||||
@@ -125,7 +153,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.incident = incident;
|
||||
}
|
||||
|
||||
// Validate maintenance_id exists for /api/maintenances/:maintenance_id/* routes
|
||||
// Validate maintenance_id exists for /api/(vX/)?maintenances/:maintenance_id/* routes
|
||||
const maintenanceId = extractMaintenanceId(pathname);
|
||||
if (maintenanceId) {
|
||||
const maintenance = await db.getMaintenanceById(maintenanceId);
|
||||
@@ -142,7 +170,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.maintenance = maintenance;
|
||||
}
|
||||
|
||||
// Validate page_path exists for /api/pages/:page_path/* routes
|
||||
// Validate page_path exists for /api/(vX/)?pages/:page_path/* routes
|
||||
const pagePath = extractPagePath(pathname);
|
||||
if (pagePath) {
|
||||
const page = await db.getPageByPath(pagePath);
|
||||
@@ -164,3 +192,5 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
response.headers.delete("Link");
|
||||
return response;
|
||||
};
|
||||
|
||||
export const handle = sequence(csrfHandle, apiAuthHandle);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import GC from "$lib/global-constants";
|
||||
|
||||
type AlertTextKind = "label" | "help" | "description";
|
||||
|
||||
interface AlertTextInput {
|
||||
kind: AlertTextKind;
|
||||
alert_for: string;
|
||||
alert_value?: string | number;
|
||||
failure_threshold?: number;
|
||||
success_threshold?: number;
|
||||
}
|
||||
|
||||
export function getAlertText({
|
||||
kind,
|
||||
alert_for,
|
||||
alert_value,
|
||||
failure_threshold,
|
||||
success_threshold,
|
||||
}: AlertTextInput): string {
|
||||
if (kind === "label") {
|
||||
switch (alert_for) {
|
||||
case GC.STATUS:
|
||||
return "Status Value";
|
||||
case GC.LATENCY:
|
||||
return "Latency Threshold (ms)";
|
||||
case GC.UPTIME:
|
||||
return "Uptime Threshold (%)";
|
||||
default:
|
||||
return "Value";
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === "help") {
|
||||
switch (alert_for) {
|
||||
case GC.STATUS:
|
||||
return "Alert when monitor status equals this value";
|
||||
case GC.LATENCY:
|
||||
return "Alert when latency exceeds this value (in milliseconds)";
|
||||
case GC.UPTIME:
|
||||
return "Alert when uptime falls below this percentage";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const thresholdValue = failure_threshold ?? 0;
|
||||
const resolveThreshold = success_threshold ?? 0;
|
||||
const value = alert_value ?? "";
|
||||
|
||||
if (alert_for === GC.STATUS) {
|
||||
return `Alert when ${thresholdValue} consecutive checks result in ${value}. Resolve after ${resolveThreshold} successful check(s).`;
|
||||
}
|
||||
|
||||
if (alert_for === GC.LATENCY) {
|
||||
return `Alert when latency exceeds ${value}ms for ${thresholdValue} consecutive checks. Resolve after ${resolveThreshold} check(s) below threshold.`;
|
||||
}
|
||||
|
||||
if (alert_for === GC.UPTIME) {
|
||||
return `Alert when uptime falls below ${value}% for ${thresholdValue} checks. Resolve after ${resolveThreshold} check(s) above threshold.`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
+8
-8
@@ -1,4 +1,4 @@
|
||||
export const DefaultAPIEval = `(async function (statusCode, responseTime, responseRaw, modules) {
|
||||
export const DefaultAPIEval = `async function (statusCode, responseTime, responseRaw, modules) {
|
||||
let statusCodeShort = Math.floor(statusCode/100);
|
||||
if(statusCode == 429 || (statusCodeShort >=2 && statusCodeShort <= 3)) {
|
||||
return {
|
||||
@@ -10,9 +10,9 @@ export const DefaultAPIEval = `(async function (statusCode, responseTime, respon
|
||||
status: 'DOWN',
|
||||
latency: responseTime,
|
||||
}
|
||||
})`;
|
||||
}`;
|
||||
|
||||
export const DefaultPingEval = `(async function (arrayOfPings) {
|
||||
export const DefaultPingEval = `async function (arrayOfPings) {
|
||||
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
|
||||
return acc + ping.latency;
|
||||
}, 0);
|
||||
@@ -25,9 +25,9 @@ export const DefaultPingEval = `(async function (arrayOfPings) {
|
||||
status: alive ? 'UP' : 'DOWN',
|
||||
latency: latencyTotal / arrayOfPings.length,
|
||||
}
|
||||
})`;
|
||||
}`;
|
||||
|
||||
export const DefaultTCPEval = `(async function (arrayOfPings) {
|
||||
export const DefaultTCPEval = `async function (arrayOfPings) {
|
||||
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
|
||||
return acc + ping.latency;
|
||||
}, 0);
|
||||
@@ -40,14 +40,14 @@ export const DefaultTCPEval = `(async function (arrayOfPings) {
|
||||
status: alive ? 'UP' : 'DOWN',
|
||||
latency: latencyTotal / arrayOfPings.length,
|
||||
}
|
||||
})`;
|
||||
}`;
|
||||
|
||||
export const DefaultGamedigEval = `(async function (responseTime, responseRaw) {
|
||||
export const DefaultGamedigEval = `async function (responseTime, responseRaw) {
|
||||
return {
|
||||
status: 'UP',
|
||||
latency: responseTime,
|
||||
}
|
||||
})`;
|
||||
}`;
|
||||
export const GAMEDIG_TIMEOUT = 10 * 1000; // 10 seconds
|
||||
export const GAMEDIG_SOCKET_TIMEOUT = 2 * 1000; // 2 seconds
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ export interface AnalyticsEventDetail {
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export const analyticsEvent = (event: string, data: unknown): void => {
|
||||
// Do something with the event and data
|
||||
const analyticsEvent = (event: string, data: unknown): void => {
|
||||
//log event data
|
||||
// console.log("Analytics Event:", event, data);
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
event = "kener_" + event;
|
||||
@@ -19,3 +20,4 @@ export const analyticsEvent = (event: string, data: unknown): void => {
|
||||
);
|
||||
}
|
||||
};
|
||||
export default analyticsEvent;
|
||||
@@ -1,20 +1,12 @@
|
||||
import { getUnixTime, startOfDay } from "date-fns";
|
||||
import { toZonedTime, fromZonedTime } from "date-fns-tz";
|
||||
|
||||
/**
|
||||
* Get current timestamp in UTC seconds
|
||||
* @returns Current UTC timestamp in seconds (Unix timestamp)
|
||||
*/
|
||||
export function getNowTimestampUTC(): number {
|
||||
return getUnixTime(new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get start of day timestamp for a given timezone
|
||||
* @param timezone - IANA timezone string (e.g., "America/New_York")
|
||||
* @returns Start of day in the given timezone as Unix timestamp (seconds)
|
||||
*/
|
||||
export function getStartOfDayAtTz(timezone: string): number {
|
||||
function getStartOfDayAtTz(timezone: string): number {
|
||||
const now = new Date();
|
||||
const zonedTime = toZonedTime(now, timezone);
|
||||
const startOfZonedDay = startOfDay(zonedTime);
|
||||
@@ -30,13 +22,3 @@ export function getStartOfDayAtTz(timezone: string): number {
|
||||
export function getEndOfDayAtTz(timezone: string): number {
|
||||
return getStartOfDayAtTz(timezone) + 86400;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current timestamp in a given timezone
|
||||
* @param timezone - IANA timezone string (e.g., "America/New_York")
|
||||
* @returns Current time in the given timezone as Unix timestamp (seconds)
|
||||
*/
|
||||
export function getNowAtTz(timezone: string): number {
|
||||
const now = new Date();
|
||||
return getUnixTime(fromZonedTime(now, timezone));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { browser } from "$app/environment";
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import type { MonitorBarResponse } from "$lib/server/api-server/monitor-bar/get";
|
||||
|
||||
interface MonitorBarsResponse {
|
||||
data: Record<string, MonitorBarResponse>;
|
||||
missingTags: string[];
|
||||
}
|
||||
|
||||
interface Waiter {
|
||||
resolve: (value: MonitorBarResponse) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}
|
||||
|
||||
interface BatchState {
|
||||
tags: Set<string>;
|
||||
waitersByTag: Map<string, Waiter[]>;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
const pendingBatches = new Map<string, BatchState>();
|
||||
|
||||
/** In-memory cache: survives across component mounts, cleared on page refresh */
|
||||
const cache = new Map<string, MonitorBarResponse>();
|
||||
|
||||
const makeBatchKey = (days: number, endOfDayTodayAtTz: number): string => `${days}:${endOfDayTodayAtTz}`;
|
||||
const makeCacheKey = (tag: string, days: number, endOfDayTodayAtTz: number): string =>
|
||||
`${tag}:${days}:${endOfDayTodayAtTz}`;
|
||||
|
||||
const flushBatch = async (key: string): Promise<void> => {
|
||||
const batch = pendingBatches.get(key);
|
||||
if (!batch) return;
|
||||
|
||||
pendingBatches.delete(key);
|
||||
const tags = Array.from(batch.tags);
|
||||
|
||||
const [daysStr, endTsStr] = key.split(":");
|
||||
const days = Number(daysStr);
|
||||
const endOfDayTodayAtTz = Number(endTsStr);
|
||||
|
||||
try {
|
||||
const query = `?tags=${encodeURIComponent(tags.join(","))}&endOfDayTodayAtTz=${endOfDayTodayAtTz}&days=${days}`;
|
||||
const response = await fetch(clientResolver(resolve, "/dashboard-apis/monitor-bars") + query);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch monitor bars");
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as MonitorBarsResponse;
|
||||
|
||||
for (const tag of tags) {
|
||||
const waiters = batch.waitersByTag.get(tag) || [];
|
||||
const item = payload.data[tag];
|
||||
if (item) {
|
||||
cache.set(makeCacheKey(tag, days, endOfDayTodayAtTz), item);
|
||||
waiters.forEach((w) => w.resolve(item));
|
||||
} else {
|
||||
const missingError = new Error(`Monitor data not found for tag: ${tag}`);
|
||||
waiters.forEach((w) => w.reject(missingError));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
for (const waiters of batch.waitersByTag.values()) {
|
||||
waiters.forEach((w) => w.reject(err));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const requestMonitorBar = (
|
||||
tag: string,
|
||||
days: number,
|
||||
endOfDayTodayAtTz: number,
|
||||
): Promise<MonitorBarResponse> => {
|
||||
if (!browser) {
|
||||
return Promise.reject(new Error("requestMonitorBar can only be called in the browser"));
|
||||
}
|
||||
|
||||
// Return cached data immediately if available
|
||||
const cacheKey = makeCacheKey(tag, days, endOfDayTodayAtTz);
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
|
||||
const key = makeBatchKey(days, endOfDayTodayAtTz);
|
||||
|
||||
return new Promise<MonitorBarResponse>((resolvePromise, rejectPromise) => {
|
||||
let batch = pendingBatches.get(key);
|
||||
if (!batch) {
|
||||
batch = {
|
||||
tags: new Set<string>(),
|
||||
waitersByTag: new Map<string, Waiter[]>(),
|
||||
timer: setTimeout(() => {
|
||||
void flushBatch(key);
|
||||
}, 0),
|
||||
};
|
||||
pendingBatches.set(key, batch);
|
||||
}
|
||||
|
||||
batch.tags.add(tag);
|
||||
const waiters = batch.waitersByTag.get(tag) || [];
|
||||
waiters.push({ resolve: resolvePromise, reject: rejectPromise });
|
||||
batch.waitersByTag.set(tag, waiters);
|
||||
});
|
||||
};
|
||||
|
||||
/** Evict all cached entries (useful if caller needs a forced refresh) */
|
||||
export const clearMonitorBarCache = (): void => {
|
||||
cache.clear();
|
||||
};
|
||||
@@ -1,3 +1 @@
|
||||
// Client-only types (safe for +page.ts / components).
|
||||
|
||||
export type ThemeMode = "light" | "dark" | "system";
|
||||
|
||||
+10
-6
@@ -362,21 +362,25 @@ function GetStatusBgColor(item: TimestampStatusCount): string {
|
||||
let textColor = GetStatusColor(item);
|
||||
return textColor.replace("text-", "bg-");
|
||||
}
|
||||
|
||||
// Get initials from monitor name for avatar fallback
|
||||
function GetInitials(name: string): string {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((word) => word[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
export {
|
||||
siteDataExtractFromDb,
|
||||
AllRecordTypes,
|
||||
ValidateIpAddress,
|
||||
IsValidHost,
|
||||
IsValidNameServer,
|
||||
IsValidURL,
|
||||
IsValidPort,
|
||||
ValidateCronExpression,
|
||||
SortMonitor,
|
||||
RandomString,
|
||||
GetGameFromId,
|
||||
GetStatusSummary,
|
||||
GetStatusColor,
|
||||
GetStatusBgColor,
|
||||
ParseLatency,
|
||||
GetInitials,
|
||||
};
|
||||
|
||||
@@ -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-3 gap-0">
|
||||
<!-- Ongoing Maintenances -->
|
||||
<div class="flex flex-col border-t border-r">
|
||||
<div class="text-muted-foreground flex items-center justify-between gap-2 border-b 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 border-t border-r">
|
||||
<div class="text-muted-foreground flex items-center justify-between gap-2 border-b 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 border-t">
|
||||
<div class="text-muted-foreground flex items-center justify-between gap-2 border-b 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}
|
||||
@@ -5,19 +5,36 @@
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import { t } from "$lib/stores/i18n";
|
||||
import trackEvent from "$lib/beacon";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
|
||||
import TrendingUp from "@lucide/svelte/icons/trending-up";
|
||||
import Percent from "@lucide/svelte/icons/percent";
|
||||
import Timer from "@lucide/svelte/icons/timer";
|
||||
import Copy from "@lucide/svelte/icons/copy";
|
||||
import Sticker from "@lucide/svelte/icons/sticker";
|
||||
import { page } from "$app/state";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
monitorTag: string;
|
||||
protocol: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), monitorTag, protocol, domain }: Props = $props();
|
||||
let { protocol, domain }: Props = $props();
|
||||
let open = $state(false);
|
||||
let showMenu = $derived(
|
||||
page.route.id === "/(kener)/monitors/[monitor_tag]" &&
|
||||
!!page.params.monitor_tag &&
|
||||
page.data.monitorSharingOptions?.showShareBadgeMonitor &&
|
||||
page.data.subMenuOptions?.showShareBadgeMonitor
|
||||
);
|
||||
let monitorTag = page.params.monitor_tag;
|
||||
|
||||
function handleBadgeCopy(
|
||||
type: "status" | "uptime" | "latency_avg" | "latency_max" | "latency_min" | "dot" | "dot_ping"
|
||||
) {
|
||||
trackEvent("badge_copied", { type, monitorTag });
|
||||
}
|
||||
|
||||
// Badge URLs
|
||||
const badgeStatusUrl = $derived(
|
||||
@@ -26,6 +43,19 @@
|
||||
const badgeUptimeUrl = $derived(
|
||||
protocol && domain ? `${protocol}//${domain}` + clientResolver(resolve, `/badge/${monitorTag}/uptime`) : ""
|
||||
);
|
||||
const badgeLatencyAvgUrl = $derived(
|
||||
protocol && domain ? `${protocol}//${domain}` + clientResolver(resolve, `/badge/${monitorTag}/latency`) : ""
|
||||
);
|
||||
const badgeLatencyMaxUrl = $derived(
|
||||
protocol && domain
|
||||
? `${protocol}//${domain}` + clientResolver(resolve, `/badge/${monitorTag}/latency?metric=maximum`)
|
||||
: ""
|
||||
);
|
||||
const badgeLatencyMinUrl = $derived(
|
||||
protocol && domain
|
||||
? `${protocol}//${domain}` + clientResolver(resolve, `/badge/${monitorTag}/latency?metric=minimum`)
|
||||
: ""
|
||||
);
|
||||
const badgeDotUrl = $derived(
|
||||
protocol && domain ? `${protocol}//${domain}` + clientResolver(resolve, `/badge/${monitorTag}/dot`) : ""
|
||||
);
|
||||
@@ -36,6 +66,19 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if showMenu}
|
||||
<Button
|
||||
variant="outline"
|
||||
class="bg-background/80 dark:bg-background/70 border-foreground/10 relative cursor-pointer rounded-full border shadow-none backdrop-blur-md"
|
||||
size="icon-sm"
|
||||
onclick={() => {
|
||||
open = true;
|
||||
trackEvent("badges_menu_opened", { source: "theme_plus" });
|
||||
}}
|
||||
>
|
||||
<Sticker />
|
||||
</Button>
|
||||
{/if}
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Overlay class="backdrop-blur-[2px]" />
|
||||
<Dialog.Content class="max-w-md rounded-3xl">
|
||||
@@ -59,7 +102,13 @@
|
||||
</div>
|
||||
<img src={badgeStatusUrl} alt="Status Badge" class="h-5" />
|
||||
|
||||
<CopyButton variant="ghost" size="icon-sm" text={badgeStatusUrl} class="rounded-btn">
|
||||
<CopyButton
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
text={badgeStatusUrl}
|
||||
class="rounded-btn"
|
||||
onclick={() => handleBadgeCopy("status")}
|
||||
>
|
||||
<Copy />
|
||||
</CopyButton>
|
||||
</div>
|
||||
@@ -72,7 +121,70 @@
|
||||
<span class="text-xs font-medium">{$t("Uptime Badge")}</span>
|
||||
</div>
|
||||
<img src={badgeUptimeUrl} alt="Uptime Badge" class="h-5" />
|
||||
<CopyButton variant="ghost" size="icon-sm" text={badgeUptimeUrl} class="rounded-btn hover:bg-transparent">
|
||||
<CopyButton
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
text={badgeUptimeUrl}
|
||||
class="rounded-btn hover:bg-transparent"
|
||||
onclick={() => handleBadgeCopy("uptime")}
|
||||
>
|
||||
<Copy />
|
||||
</CopyButton>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if badgeLatencyAvgUrl}
|
||||
<div class="flex items-center justify-between gap-2 rounded-3xl border px-2 py-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Timer class="h-3 w-3" />
|
||||
<span class="text-xs font-medium">{$t("Avg Latency")}</span>
|
||||
</div>
|
||||
<img src={badgeLatencyAvgUrl} alt="Avg Latency Badge" class="h-5" />
|
||||
<CopyButton
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
text={badgeLatencyAvgUrl}
|
||||
class="rounded-btn"
|
||||
onclick={() => handleBadgeCopy("latency_avg")}
|
||||
>
|
||||
<Copy />
|
||||
</CopyButton>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if badgeLatencyMaxUrl}
|
||||
<div class="flex items-center justify-between gap-2 rounded-3xl border px-2 py-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Timer class="h-3 w-3" />
|
||||
<span class="text-xs font-medium">{$t("Max Latency")}</span>
|
||||
</div>
|
||||
<img src={badgeLatencyMaxUrl} alt="Max Latency Badge" class="h-5" />
|
||||
<CopyButton
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
text={badgeLatencyMaxUrl}
|
||||
class="rounded-btn"
|
||||
onclick={() => handleBadgeCopy("latency_max")}
|
||||
>
|
||||
<Copy />
|
||||
</CopyButton>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if badgeLatencyMinUrl}
|
||||
<div class="flex items-center justify-between gap-2 rounded-3xl border px-2 py-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Timer class="h-3 w-3" />
|
||||
<span class="text-xs font-medium">{$t("Min Latency")}</span>
|
||||
</div>
|
||||
<img src={badgeLatencyMinUrl} alt="Min Latency Badge" class="h-5" />
|
||||
<CopyButton
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
text={badgeLatencyMinUrl}
|
||||
class="rounded-btn"
|
||||
onclick={() => handleBadgeCopy("latency_min")}
|
||||
>
|
||||
<Copy />
|
||||
</CopyButton>
|
||||
</div>
|
||||
@@ -89,7 +201,13 @@
|
||||
<div class="flex items-center gap-2 rounded-3xl border px-2 py-1">
|
||||
<img src={badgeDotUrl} alt="Status Dot" class="h-4" />
|
||||
<span class="text-xs font-medium">{$t("Standard")}</span>
|
||||
<CopyButton variant="ghost" size="icon-sm" text={badgeDotUrl} class="rounded-btn">
|
||||
<CopyButton
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
text={badgeDotUrl}
|
||||
class="rounded-btn"
|
||||
onclick={() => handleBadgeCopy("dot")}
|
||||
>
|
||||
<Copy />
|
||||
</CopyButton>
|
||||
</div>
|
||||
@@ -98,7 +216,13 @@
|
||||
<div class="flex items-center gap-2 rounded-3xl border px-2 py-1">
|
||||
<img src={badgeDotPingUrl} alt="Status Dot Ping" class="h-4" />
|
||||
<span class="text-xs font-medium">{$t("Pinging")}</span>
|
||||
<CopyButton variant="ghost" size="icon-sm" text={badgeDotPingUrl} class="rounded-btn">
|
||||
<CopyButton
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
text={badgeDotPingUrl}
|
||||
class="rounded-btn"
|
||||
onclick={() => handleBadgeCopy("dot_ping")}
|
||||
>
|
||||
<Copy />
|
||||
</CopyButton>
|
||||
</div>
|
||||
|
||||
@@ -2,31 +2,65 @@
|
||||
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
||||
import { Label } from "$lib/components/ui/label/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import * as ButtonGroup from "$lib/components/ui/button-group/index.js";
|
||||
import CopyButton from "$lib/components/CopyButton.svelte";
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import { t } from "$lib/stores/i18n";
|
||||
import { mode } from "mode-watcher";
|
||||
import trackEvent from "$lib/beacon";
|
||||
import { page } from "$app/state";
|
||||
|
||||
import Copy from "@lucide/svelte/icons/clipboard";
|
||||
import Check from "@lucide/svelte/icons/check";
|
||||
import Code from "@lucide/svelte/icons/code";
|
||||
import ExternalLink from "@lucide/svelte/icons/external-link";
|
||||
import Copy from "@lucide/svelte/icons/copy";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
monitorTag: string;
|
||||
protocol: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), monitorTag, protocol, domain }: Props = $props();
|
||||
|
||||
let { protocol, domain }: Props = $props();
|
||||
let open = $state(false);
|
||||
let showMenu = $derived(
|
||||
page.route.id === "/(kener)/monitors/[monitor_tag]" &&
|
||||
!!page.params.monitor_tag &&
|
||||
page.data.monitorSharingOptions?.showShareEmbedMonitor &&
|
||||
page.data.subMenuOptions?.showShareEmbedMonitor
|
||||
);
|
||||
let monitorTag = page.params.monitor_tag;
|
||||
let monitorTheme = $state<"light" | "dark">(mode.current === "dark" ? "dark" : "light");
|
||||
let monitorEmbedType = $state<"script" | "iframe">("iframe");
|
||||
|
||||
let latencyTheme = $state<"light" | "dark">(mode.current === "dark" ? "dark" : "light");
|
||||
let latencyEmbedType = $state<"script" | "iframe">("iframe");
|
||||
|
||||
function setMonitorTheme(theme: "light" | "dark") {
|
||||
monitorTheme = theme;
|
||||
trackEvent("embed_theme_changed", { target: "monitor", theme, monitorTag });
|
||||
}
|
||||
|
||||
function setMonitorEmbedType(type: "script" | "iframe") {
|
||||
monitorEmbedType = type;
|
||||
trackEvent("embed_format_changed", { target: "monitor", format: type, monitorTag });
|
||||
}
|
||||
|
||||
function setLatencyTheme(theme: "light" | "dark") {
|
||||
latencyTheme = theme;
|
||||
trackEvent("embed_theme_changed", { target: "latency", theme, monitorTag });
|
||||
}
|
||||
|
||||
function setLatencyEmbedType(type: "script" | "iframe") {
|
||||
latencyEmbedType = type;
|
||||
trackEvent("embed_format_changed", { target: "latency", format: type, monitorTag });
|
||||
}
|
||||
|
||||
function handleEmbedCopy(target: "monitor" | "latency") {
|
||||
const theme = target === "monitor" ? monitorTheme : latencyTheme;
|
||||
const format = target === "monitor" ? monitorEmbedType : latencyEmbedType;
|
||||
trackEvent("embed_code_copied", { target, theme, format, monitorTag });
|
||||
}
|
||||
|
||||
// Monitor Embed URL
|
||||
const monitorEmbedUrl = $derived(() => {
|
||||
if (!protocol || !domain) return "";
|
||||
@@ -78,9 +112,22 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if showMenu}
|
||||
<Button
|
||||
variant="outline"
|
||||
class="bg-background/80 dark:bg-background/70 border-foreground/10 relative cursor-pointer rounded-full border shadow-none backdrop-blur-md"
|
||||
size="icon-sm"
|
||||
onclick={() => {
|
||||
open = true;
|
||||
trackEvent("embed_menu_opened", { source: "theme_plus" });
|
||||
}}
|
||||
>
|
||||
<Code />
|
||||
</Button>
|
||||
{/if}
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Overlay class="backdrop-blur-[2px]" />
|
||||
<Dialog.Content class="min-w-157.5 rounded-3xl">
|
||||
<Dialog.Content class="max-w-2xl rounded-3xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t("Embed Monitor")}</Dialog.Title>
|
||||
<Dialog.Description>{$t("Embed this monitor in your website or app")}</Dialog.Description>
|
||||
@@ -90,53 +137,54 @@
|
||||
<!-- Status Embed -->
|
||||
<div>
|
||||
<Label class="mb-2 block text-sm font-semibold">{$t("Status Embed")}</Label>
|
||||
<div class="flex flex-col gap-4 rounded-3xl border p-4">
|
||||
<div class="flex flex-col gap-3 rounded-3xl border p-4">
|
||||
<iframe title="status embed preview" src={monitorPreviewUrl()} width="100%" height="70" frameborder="0"
|
||||
></iframe>
|
||||
<div class="flex items-center justify-between gap-4 p-2">
|
||||
<div>
|
||||
<Label class="mb-2 block text-xs">{$t("Theme")}</Label>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant={monitorTheme === "light" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onclick={() => (monitorTheme = "light")}
|
||||
>
|
||||
{$t("Light")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={monitorTheme === "dark" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onclick={() => (monitorTheme = "dark")}
|
||||
>
|
||||
{$t("Dark")}
|
||||
</Button>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonGroup.Root class="rounded-btn-grp">
|
||||
<Button variant="outline" size="sm" onclick={() => setMonitorTheme("light")}>
|
||||
{#if monitorTheme === "light"}
|
||||
<Check class="text-accent-foreground h-3 w-3" />
|
||||
{/if}
|
||||
{$t("Light")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onclick={() => setMonitorTheme("dark")}>
|
||||
{#if monitorTheme === "dark"}
|
||||
<Check class="text-accent-foreground h-3 w-3" />
|
||||
{/if}
|
||||
{$t("Dark")}
|
||||
</Button>
|
||||
</ButtonGroup.Root>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonGroup.Root class="rounded-btn-grp">
|
||||
<Button variant="outline" size="sm" onclick={() => setMonitorEmbedType("iframe")}>
|
||||
{#if monitorEmbedType === "iframe"}
|
||||
<Check class="text-accent-foreground h-3 w-3" />
|
||||
{/if}
|
||||
|
||||
{$t("iFrame")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onclick={() => setMonitorEmbedType("script")}>
|
||||
{#if monitorEmbedType === "script"}
|
||||
<Check class="text-accent-foreground h-3 w-3" />
|
||||
{/if}
|
||||
{$t("Script")}
|
||||
</Button>
|
||||
</ButtonGroup.Root>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-2 block text-xs">{$t("Format")}</Label>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant={monitorEmbedType === "iframe" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onclick={() => (monitorEmbedType = "iframe")}
|
||||
>
|
||||
{$t("iFrame")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={monitorEmbedType === "script" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onclick={() => (monitorEmbedType = "script")}
|
||||
>
|
||||
{$t("Script")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<CopyButton variant="ghost" size="icon-sm" text={monitorEmbedCode()} class="rounded-btn">
|
||||
<Copy class="h-3 w-3" />
|
||||
</CopyButton>
|
||||
</div>
|
||||
<CopyButton
|
||||
variant="outline"
|
||||
class="rounded-btn"
|
||||
size="icon-sm"
|
||||
text={monitorEmbedCode()}
|
||||
onclick={() => handleEmbedCopy("monitor")}
|
||||
>
|
||||
<Copy />
|
||||
</CopyButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,53 +192,53 @@
|
||||
<!-- Latency Embed -->
|
||||
<div>
|
||||
<Label class="mb-2 block text-sm font-semibold">{$t("Latency Embed")}</Label>
|
||||
<div class="flex flex-col gap-4 rounded-3xl border p-4">
|
||||
<div class="flex flex-col gap-3 rounded-3xl border p-4">
|
||||
<iframe title="latency embed preview" src={latencyPreviewUrl()} width="100%" height="200" frameborder="0"
|
||||
></iframe>
|
||||
<div class="flex items-end justify-between gap-4 p-2">
|
||||
<div>
|
||||
<Label class="mb-2 block text-xs">{$t("Theme")}</Label>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant={latencyTheme === "light" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onclick={() => (latencyTheme = "light")}
|
||||
>
|
||||
{$t("Light")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={latencyTheme === "dark" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onclick={() => (latencyTheme = "dark")}
|
||||
>
|
||||
{$t("Dark")}
|
||||
</Button>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonGroup.Root class="rounded-btn-grp">
|
||||
<Button variant="outline" size="sm" onclick={() => setLatencyTheme("light")}>
|
||||
{#if latencyTheme === "light"}
|
||||
<Check class="text-accent-foreground h-3 w-3" />
|
||||
{/if}
|
||||
{$t("Light")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onclick={() => setLatencyTheme("dark")}>
|
||||
{#if latencyTheme === "dark"}
|
||||
<Check class="text-accent-foreground h-3 w-3" />
|
||||
{/if}
|
||||
{$t("Dark")}
|
||||
</Button>
|
||||
</ButtonGroup.Root>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonGroup.Root class="rounded-btn-grp">
|
||||
<Button variant="outline" size="sm" onclick={() => setLatencyEmbedType("iframe")}>
|
||||
{#if latencyEmbedType === "iframe"}
|
||||
<Check class="text-accent-foreground h-3 w-3" />
|
||||
{/if}
|
||||
{$t("iFrame")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onclick={() => setLatencyEmbedType("script")}>
|
||||
{#if latencyEmbedType === "script"}
|
||||
<Check class="text-accent-foreground h-3 w-3" />
|
||||
{/if}
|
||||
{$t("Script")}
|
||||
</Button>
|
||||
</ButtonGroup.Root>
|
||||
</div>
|
||||
</div>
|
||||
<div class="">
|
||||
<Label class="mb-2 block text-xs">{$t("Format")}</Label>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant={latencyEmbedType === "iframe" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onclick={() => (latencyEmbedType = "iframe")}
|
||||
>
|
||||
{$t("iFrame")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={latencyEmbedType === "script" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onclick={() => (latencyEmbedType = "script")}
|
||||
>
|
||||
{$t("Script")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<CopyButton variant="ghost" size="icon-sm" text={latencyEmbedCode()} class="rounded-btn">
|
||||
<Copy class="h-3 w-3" />
|
||||
</CopyButton>
|
||||
</div>
|
||||
<CopyButton
|
||||
variant="outline"
|
||||
class="rounded-btn"
|
||||
size="icon-sm"
|
||||
text={latencyEmbedCode()}
|
||||
onclick={() => handleEmbedCopy("latency")}
|
||||
>
|
||||
<Copy />
|
||||
</CopyButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,90 +4,26 @@
|
||||
import { t } from "$lib/stores/i18n";
|
||||
|
||||
interface Props {
|
||||
ongoingMaintenancesCount: number;
|
||||
ongoingIncidentsCount: number;
|
||||
upcomingMaintenancesCount: number;
|
||||
statusClass: string;
|
||||
statusText: string;
|
||||
pageSettings: PageSettingsType | null;
|
||||
}
|
||||
|
||||
let {
|
||||
ongoingMaintenancesCount,
|
||||
ongoingIncidentsCount,
|
||||
upcomingMaintenancesCount,
|
||||
statusClass,
|
||||
statusText,
|
||||
pageSettings
|
||||
}: Props = $props();
|
||||
let { statusClass, statusText }: Props = $props();
|
||||
// Compute change info (direction, color, and formatted value)
|
||||
|
||||
//is maintenance show enabled
|
||||
let showMaintenance = $derived(pageSettings?.include_maintenances.enabled ?? false);
|
||||
let showIncidents = $derived(pageSettings?.incidents.enabled ?? false);
|
||||
let showAny = $derived(showMaintenance || showIncidents);
|
||||
</script>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<div class="flex h-20 flex-row justify-start gap-y-3 rounded-3xl border p-4">
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
<div class="flex flex-col gap-3 lg:flex-row">
|
||||
<div class="flex min-h-20 w-full flex-row justify-start gap-y-3 rounded-3xl border p-4 lg:w-full lg:min-w-72">
|
||||
<div class="flex w-full flex-row items-center gap-4">
|
||||
<div class="relative flex justify-between">
|
||||
<span class="relative flex size-4">
|
||||
<span class="{statusClass} absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
|
||||
<span class="{statusClass} relative inline-flex size-4 rounded-full"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<p class=" text-2xl">{$t(statusText)}</p>
|
||||
<div class="flex min-w-0 flex-col items-start gap-2">
|
||||
<p class="text-xl leading-tight wrap-break-word sm:text-2xl">{$t(statusText)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if showAny}
|
||||
<div class="flex h-20 flex-1 flex-col justify-around gap-y-4 rounded-3xl border p-4">
|
||||
<div class=" flex gap-x-3">
|
||||
{#if showIncidents}
|
||||
<div class="flex flex-1 flex-row items-center gap-2">
|
||||
{#if ongoingIncidentsCount === 0}
|
||||
<Check class="text-up" />
|
||||
{:else}
|
||||
<p class="text-3xl">
|
||||
{ongoingIncidentsCount}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-xs leading-4 font-medium">
|
||||
{@html $t("Ongoing <br /> Incidents")}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showMaintenance}
|
||||
<div class="flex flex-1 flex-row items-center gap-2">
|
||||
{#if ongoingMaintenancesCount === 0}
|
||||
<Check class="text-up" />
|
||||
{:else}
|
||||
<p class="text-3xl">
|
||||
{ongoingMaintenancesCount}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<p class="text-xs leading-4 font-medium">
|
||||
{@html $t("Ongoing <br /> Maintenances")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-row items-center gap-2">
|
||||
{#if upcomingMaintenancesCount === 0}
|
||||
<Check class="text-up" />
|
||||
{:else}
|
||||
<p class="text-3xl">
|
||||
{upcomingMaintenancesCount}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-xs leading-4 font-medium">
|
||||
{@html $t("Upcoming <br /> Maintenances")}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import type { Snippet } from "svelte";
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import * as Drawer from "$lib/components/ui/drawer/index.js";
|
||||
import { requestMonitorBar } from "$lib/client/monitor-bar-client";
|
||||
import type { MonitorBarResponse } from "$lib/server/api-server/monitor-bar/get";
|
||||
import MonitorBar from "$lib/components/MonitorBar.svelte";
|
||||
import { t } from "$lib/stores/i18n";
|
||||
|
||||
interface Props {
|
||||
tags: string[];
|
||||
days: number;
|
||||
endOfDayTodayAtTz: number;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { tags, days, endOfDayTodayAtTz, children }: Props = $props();
|
||||
let isOpen = $state(false);
|
||||
let monitorBarPromiseByTag = $derived.by(() => {
|
||||
if (!browser || !isOpen || tags.length === 0) {
|
||||
return {} as Partial<Record<string, Promise<MonitorBarResponse>>>;
|
||||
}
|
||||
|
||||
return Object.fromEntries(tags.map((tag) => [tag, requestMonitorBar(tag, days, endOfDayTodayAtTz)])) as Partial<
|
||||
Record<string, Promise<MonitorBarResponse>>
|
||||
>;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
<Drawer.Root bind:open={isOpen} direction="bottom">
|
||||
<Drawer.Trigger
|
||||
class={buttonVariants({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
class: "rounded-btn bg-secondary w-full text-xs"
|
||||
})}
|
||||
>
|
||||
{@render children()}
|
||||
</Drawer.Trigger>
|
||||
<Drawer.Content class="max-h-[80vh]">
|
||||
<Drawer.Header>
|
||||
<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}
|
||||
<div class="text-muted-foreground p-4 text-sm">{$t("No monitors available.")}</div>
|
||||
{:else}
|
||||
{#each tags as tag, i (tag)}
|
||||
<div class="{i < tags.length - 1 ? 'border-b' : ''} py-2 pb-4">
|
||||
{#if monitorBarPromiseByTag[tag]}
|
||||
{#await monitorBarPromiseByTag[tag]}
|
||||
<MonitorBar {tag} />
|
||||
{:then monitorBarData}
|
||||
<MonitorBar {tag} prefetchedData={monitorBarData} />
|
||||
{:catch err}
|
||||
<MonitorBar {tag} prefetchedError={err instanceof Error ? err.message : "Unknown error"} />
|
||||
{/await}
|
||||
{:else}
|
||||
<MonitorBar {tag} />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root>
|
||||
</div>
|
||||
@@ -5,162 +5,180 @@
|
||||
import ArrowRight from "@lucide/svelte/icons/arrow-right";
|
||||
import * as Popover from "$lib/components/ui/popover/index.js";
|
||||
import * as Avatar from "$lib/components/ui/avatar/index.js";
|
||||
import STATUS_ICON from "$lib/icons";
|
||||
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 IncidentMonitorImpact {
|
||||
monitor_tag: string;
|
||||
monitor_impact: string;
|
||||
monitor_name: string;
|
||||
monitor_image: string | null;
|
||||
}
|
||||
|
||||
interface Incident {
|
||||
id: number;
|
||||
title: string;
|
||||
monitors: IncidentMonitorImpact[];
|
||||
start_date_time: number;
|
||||
end_date_time?: number | null;
|
||||
}
|
||||
import { GetInitials } from "$lib/clientTools.js";
|
||||
import type { IncidentCommentRecord, IncidentForMonitorListWithComments } from "$lib/server/types/db";
|
||||
import { SveltePurify } from "@humanspeak/svelte-purify";
|
||||
import mdToHTML from "$lib/marked";
|
||||
import { slide } from "svelte/transition";
|
||||
import { page } from "$app/state";
|
||||
|
||||
interface Props {
|
||||
incident: Incident;
|
||||
incident: IncidentForMonitorListWithComments;
|
||||
class?: string;
|
||||
hideMonitors?: boolean;
|
||||
showComments?: boolean;
|
||||
showSummary?: boolean;
|
||||
}
|
||||
|
||||
let { incident, class: className = "", hideMonitors = false }: Props = $props();
|
||||
const STATUS_STROKE = {
|
||||
UP: "stroke-up",
|
||||
DOWN: "stroke-down",
|
||||
DEGRADED: "stroke-degraded",
|
||||
MAINTENANCE: "stroke-maintenance",
|
||||
NO_DATA: "stroke-muted-foreground"
|
||||
} as const;
|
||||
|
||||
// Get the highest severity impact from monitors array
|
||||
function getHighestImpact(monitors: IncidentMonitorImpact[]): keyof typeof STATUS_ICON {
|
||||
const priority: (keyof typeof STATUS_ICON)[] = ["DOWN", "DEGRADED", "MAINTENANCE"];
|
||||
for (const impact of priority) {
|
||||
if (monitors.some((m) => m.monitor_impact === impact)) {
|
||||
return impact;
|
||||
}
|
||||
}
|
||||
return (monitors[0]?.monitor_impact as keyof typeof STATUS_ICON) || "NO_DATA";
|
||||
}
|
||||
|
||||
// Get initials from monitor name for avatar fallback
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((word) => word[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
const highestImpact = $derived(getHighestImpact(incident.monitors));
|
||||
const Icon = $derived(STATUS_ICON[highestImpact]);
|
||||
const strokeClass = $derived(STATUS_STROKE[highestImpact as keyof typeof STATUS_STROKE] || "stroke-down");
|
||||
let {
|
||||
incident,
|
||||
class: className = "",
|
||||
hideMonitors = false,
|
||||
showComments = true,
|
||||
showSummary = true
|
||||
}: Props = $props();
|
||||
|
||||
// Calculate duration between start and end (or now if ongoing)
|
||||
// If ongoing, use current timestamp for duration calculation
|
||||
const endTimeForDuration = $derived(incident.end_date_time ?? Math.floor(Date.now() / 1000));
|
||||
|
||||
const isEmbedded = page.route.id?.includes("(embed)");
|
||||
const target = isEmbedded ? "_blank" : "_self";
|
||||
</script>
|
||||
|
||||
<Item.Root class="p-0 {className}">
|
||||
<Item.Media>
|
||||
<Icon class="size-6 {strokeClass}" />
|
||||
</Item.Media>
|
||||
<Item.Content>
|
||||
<div class="flex items-center gap-2">
|
||||
<Item.Title>{incident.title}</Item.Title>
|
||||
<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()}">{$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}
|
||||
</a>
|
||||
</Item.Title>
|
||||
</div>
|
||||
|
||||
{#if incident.monitors && incident.monitors.length > 0 && !hideMonitors}
|
||||
<div class="*:data-[slot=avatar]:ring-background my-1 flex -space-x-2 *:data-[slot=avatar]:ring-2">
|
||||
{#each incident.monitors as monitor}
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Avatar.Root
|
||||
class="bg-background size-8 cursor-pointer border-2 border-{monitor.monitor_impact.toLowerCase()} transition-transform duration-100 ease-in-out hover:scale-[1.1] hover:border"
|
||||
<div class="my-1 overflow-x-auto p-1">
|
||||
<div class="flex gap-2">
|
||||
{#each incident.monitors as monitor (`${incident.id}-${monitor.monitor_tag}`)}
|
||||
<Popover.Root>
|
||||
<Popover.Trigger disabled={isEmbedded}>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="border-{monitor.monitor_impact.toLowerCase()} cursor-pointer rounded-none border-0 border-b px-0 text-sm font-normal"
|
||||
>
|
||||
{monitor.monitor_name}
|
||||
</Badge>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
class="bg-background/60 border-border w-64 rounded-3xl border shadow-2xl backdrop-blur-xl"
|
||||
>
|
||||
{#if monitor.monitor_image}
|
||||
<Avatar.Image
|
||||
src={clientResolver(resolve, monitor.monitor_image)}
|
||||
alt={monitor.monitor_name}
|
||||
class="object-cover"
|
||||
/>
|
||||
{/if}
|
||||
<Avatar.Fallback class="text-xs">{getInitials(monitor.monitor_name)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-64">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar.Root>
|
||||
{#if monitor.monitor_image}
|
||||
<Avatar.Image src={clientResolver(resolve, monitor.monitor_image)} alt={monitor.monitor_name} />
|
||||
{/if}
|
||||
<Avatar.Fallback>{getInitials(monitor.monitor_name)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{monitor.monitor_name}</span>
|
||||
<span class="text-muted-foreground text-xs">{monitor.monitor_tag}</span>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar.Root>
|
||||
{#if monitor.monitor_image}
|
||||
<Avatar.Image src={clientResolver(resolve, monitor.monitor_image)} alt={monitor.monitor_name} />
|
||||
{/if}
|
||||
<Avatar.Fallback>{GetInitials(monitor.monitor_name)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{monitor.monitor_name}</span>
|
||||
<span class="text-muted-foreground text-xs">{monitor.monitor_tag}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Badge variant="outline" class="text-{monitor.monitor_impact.toLowerCase()}">
|
||||
{$t(monitor.monitor_impact)}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="rounded-btn"
|
||||
size="icon-sm"
|
||||
href={clientResolver(resolve, `/monitors/${monitor.monitor_tag}`)}
|
||||
>
|
||||
<ArrowRight class="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Badge variant="outline" class="text-{monitor.monitor_impact.toLowerCase()}">
|
||||
{$t(monitor.monitor_impact)}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="rounded-btn"
|
||||
size="icon-sm"
|
||||
href={clientResolver(resolve, `/monitors/${monitor.monitor_tag}`)}
|
||||
>
|
||||
<ArrowRight class="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
{/each}
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Item.Description class="mt-2 flex items-center justify-between text-xs font-medium">
|
||||
<span class="rounded-full border px-3 py-2">
|
||||
{$formatDate(incident.start_date_time, "PPp")}
|
||||
<Item.Description
|
||||
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, page.data.dateAndTimeFormat.datePlusTime)}
|
||||
</span>
|
||||
<span class="relative flex-1 text-center">
|
||||
<span class="absolute top-1/2 right-0 left-0 border-t"></span>
|
||||
<span class="bg-background relative z-10 px-2 py-1"
|
||||
>{$formatDuration(incident.start_date_time, endTimeForDuration)}</span
|
||||
>
|
||||
<span class="relative w-full text-center sm:flex-1">
|
||||
<span
|
||||
class="absolute top-0 bottom-0 left-1/2 border-l sm:top-1/2 sm:right-0 sm:bottom-auto sm:left-0 sm:border-t sm:border-l-0"
|
||||
></span>
|
||||
<span class="bg-background relative z-10 rounded-full px-0 py-1 sm:px-2">
|
||||
{$formatDuration(incident.start_date_time, endTimeForDuration)}
|
||||
</span>
|
||||
</span>
|
||||
{#if incident.end_date_time}
|
||||
<span class="rounded-full border px-3 py-2">
|
||||
{$formatDate(incident.end_date_time, "PPp")}
|
||||
<span class="max-w-full rounded-full border px-3 py-2 wrap-break-word">
|
||||
{$formatDate(incident.end_date_time, page.data.dateAndTimeFormat.datePlusTime)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="rounded-full border px-3 py-2">
|
||||
<span class="max-w-full rounded-full border px-3 py-2 wrap-break-word">
|
||||
{$t("Ongoing")}
|
||||
</span>
|
||||
{/if}
|
||||
</Item.Description>
|
||||
{#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>{$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>{$t("Status")}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-{incident.state.toLowerCase()}">
|
||||
{$t(incident.state)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="text-muted-foreground bg-secondary flex items-center justify-between gap-2 rounded-full border p-2 px-4"
|
||||
>
|
||||
<span>
|
||||
{incident.comments && incident.comments.length > 0
|
||||
? `${incident.comments.length} ${$t("Updates")}`
|
||||
: $t("No Updates")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
class="rounded-btn -mr-2 {isEmbedded ? 'hidden' : ''}"
|
||||
onclick={() => (showComments = !showComments)}
|
||||
>
|
||||
<ArrowRight class={`transition-transform duration-200 ${showComments ? "rotate-90" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showSummary && showComments && incident.comments && incident.comments.length > 0}
|
||||
<div transition:slide={{ duration: 220 }} class=" flex flex-col gap-4">
|
||||
{#each incident.comments as comment (comment.id)}
|
||||
<div class="flex flex-col gap-2 border-b pb-4 last:border-b-0 last:pb-0">
|
||||
<div class="flex justify-start gap-2">
|
||||
<Badge variant="outline" class="text-{comment.state.toLowerCase()} rounded-none border-0 p-0">
|
||||
{$t(comment.state)}
|
||||
</Badge>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{$formatDate(comment.commented_at, page.data.dateAndTimeFormat.datePlusTime)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="prose prose-sm dark:prose-invert max-w-none min-w-0 overflow-x-auto wrap-break-word"
|
||||
style="font-size: 14px;"
|
||||
>
|
||||
<SveltePurify html={mdToHTML(comment.comment)} />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Item.Content>
|
||||
<Item.Actions>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="cursor-pointer rounded-full shadow-none"
|
||||
href={clientResolver(resolve, `/incidents/${incident.id}`)}
|
||||
size="icon"
|
||||
>
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
</Item.Actions>
|
||||
</Item.Root>
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||
import IncidentItem from "$lib/components/IncidentItem.svelte";
|
||||
|
||||
interface IncidentMonitorImpact {
|
||||
monitor_tag: string;
|
||||
monitor_impact: string;
|
||||
monitor_name: string;
|
||||
monitor_image: string | null;
|
||||
}
|
||||
|
||||
interface Incident {
|
||||
id: number;
|
||||
title: string;
|
||||
monitors: IncidentMonitorImpact[];
|
||||
start_date_time: number;
|
||||
end_date_time?: number | null;
|
||||
}
|
||||
import type { IncidentForMonitorListWithComments } from "$lib/server/types/db";
|
||||
|
||||
interface Props {
|
||||
incidents: Incident[];
|
||||
incidents: IncidentForMonitorListWithComments[];
|
||||
title: string;
|
||||
class?: string;
|
||||
}
|
||||
@@ -26,17 +12,8 @@
|
||||
let { incidents, title, class: className = "" }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if incidents && incidents.length > 0}
|
||||
<div class="bg-background rounded-3xl border p-0 {className}">
|
||||
<div class="flex items-center justify-between p-4">
|
||||
<Badge variant="secondary" class="gap-1">{title}</Badge>
|
||||
</div>
|
||||
<div class="">
|
||||
{#each incidents as incident (incident.id)}
|
||||
<div class="border-b p-4 last:border-b-0">
|
||||
<IncidentItem {incident} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#each incidents as incident (incident.id)}
|
||||
<div class=" rounded-3xl border p-3 sm:p-4">
|
||||
<IncidentItem {incident} />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -1,23 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import * as NavigationMenu from "$lib/components/ui/navigation-menu/index.js";
|
||||
import * as Dropdown from "$lib/components/ui/dropdown-menu/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { navigationMenuTriggerStyle } from "$lib/components/ui/navigation-menu/navigation-menu-trigger.svelte";
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import trackEvent from "$lib/beacon";
|
||||
import MenuIcon from "@lucide/svelte/icons/menu";
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
function trackNavClick(item: { name: string; url: string }) {
|
||||
trackEvent("nav_link_clicked", { name: item.name, external: item.url.startsWith("http") });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-x-0 top-0 z-10 py-2">
|
||||
<div class="mx-auto max-w-5xl">
|
||||
<div class="bg-background flex items-center justify-between rounded-3xl border p-1">
|
||||
<div class="mx-auto max-w-5xl px-4">
|
||||
<div
|
||||
class="bg-background/80 dark:bg-background/70 flex items-center justify-between rounded-3xl border p-1 backdrop-blur-md"
|
||||
>
|
||||
<!-- Brand -->
|
||||
<a
|
||||
href={clientResolver(resolve, siteUrl)}
|
||||
class="{navigationMenuTriggerStyle()} hover:border-border border border-transparent text-xs hover:bg-transparent"
|
||||
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}
|
||||
>
|
||||
{#if logo}
|
||||
<img src={clientResolver(resolve, logo)} alt={siteName} class="mr-2 h-6 w-6 rounded-full object-cover" />
|
||||
@@ -25,22 +48,28 @@
|
||||
{siteName}
|
||||
</a>
|
||||
|
||||
<!-- Nav Links -->
|
||||
<NavigationMenu.Root>
|
||||
<!-- Desktop Nav Links -->
|
||||
<NavigationMenu.Root class="hidden sm:block">
|
||||
<NavigationMenu.List>
|
||||
{#each navItems as item}
|
||||
{#each navItems as item (item.url)}
|
||||
<NavigationMenu.Item>
|
||||
<NavigationMenu.Link>
|
||||
{#snippet child()}
|
||||
<a
|
||||
data-sveltekit-preload-data="off"
|
||||
href={clientResolver(resolve, item.url)}
|
||||
class="{navigationMenuTriggerStyle()} hover:border-border border border-transparent text-xs hover:bg-transparent"
|
||||
class="{navigationMenuTriggerStyle()} hover:border-border border border-transparent bg-transparent text-xs hover:bg-transparent"
|
||||
target={item.url.startsWith("http") ? "_blank" : undefined}
|
||||
rel={item.url.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||
style="border-radius: var(--radius-3xl)"
|
||||
onclick={() => trackNavClick(item)}
|
||||
>
|
||||
{#if item.iconURL}
|
||||
<img src={clientResolver(resolve, item.iconURL)} alt={item.name} class="mr-2 h-4 w-4" />
|
||||
<img
|
||||
src={clientResolver(resolve, item.iconURL)}
|
||||
alt={item.name}
|
||||
class="mr-2 h-4 w-4 object-cover"
|
||||
/>
|
||||
{/if}
|
||||
{item.name}
|
||||
</a>
|
||||
@@ -50,6 +79,43 @@
|
||||
{/each}
|
||||
</NavigationMenu.List>
|
||||
</NavigationMenu.Root>
|
||||
|
||||
<!-- Mobile Nav -->
|
||||
{#if navItems.length > 0}
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
type="button"
|
||||
class="{navigationMenuTriggerStyle()} hover:border-border border border-transparent bg-transparent text-xs hover:bg-transparent sm:hidden"
|
||||
style="border-radius: var(--radius-3xl)"
|
||||
aria-label="Open navigation menu"
|
||||
>
|
||||
<MenuIcon class="h-4 w-4" />
|
||||
</button>
|
||||
{/snippet}
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Content align="end" class="w-56 rounded-3xl p-2">
|
||||
{#each navItems as item (item.url)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
href={clientResolver(resolve, item.url)}
|
||||
class="w-full justify-start rounded-full text-xs"
|
||||
target={item.url.startsWith("http") ? "_blank" : undefined}
|
||||
rel={item.url.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||
onclick={() => trackNavClick(item)}
|
||||
>
|
||||
{#if item.iconURL}
|
||||
<img src={clientResolver(resolve, item.iconURL)} alt={item.name} class="mr-2 h-4 w-4 object-cover" />
|
||||
{/if}
|
||||
{item.name}
|
||||
</Button>
|
||||
{/each}
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
<script lang="ts">
|
||||
import * as Select from "$lib/components/ui/select/index.js";
|
||||
import { i18n, t } from "$lib/stores/i18n";
|
||||
import Languages from "lucide-svelte/icons/languages";
|
||||
import Languages from "@lucide/svelte/icons/languages";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import trackEvent from "$lib/beacon";
|
||||
|
||||
interface Props {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
let { compact = false }: Props = $props();
|
||||
|
||||
// Get the current locale from the store
|
||||
let selectedLang = $state($i18n.currentLocale);
|
||||
@@ -15,6 +23,7 @@
|
||||
async function handleLocaleChange(newLocale: string) {
|
||||
if (newLocale && newLocale !== $i18n.currentLocale) {
|
||||
await i18n.setLocale(newLocale);
|
||||
trackEvent("language_changed", { locale: newLocale });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,10 +43,17 @@
|
||||
<Select.Root type="single" name="language" bind:value={selectedLang}>
|
||||
<Select.Trigger
|
||||
size="sm"
|
||||
class="ksel hover:text-accent-foreground bg-background/80 dark:bg-background/70 border-foreground/10 cursor-pointer rounded-full border text-xs font-medium shadow-none backdrop-blur-md"
|
||||
class={cn(
|
||||
"ksel hover:text-accent-foreground bg-background/80 dark:bg-background/70 border-foreground/15 flex cursor-pointer justify-center rounded-full border text-xs font-medium shadow-none backdrop-blur-md",
|
||||
compact ? "size-8 p-0" : "max-w-[10rem] sm:max-w-none"
|
||||
)}
|
||||
>
|
||||
<Languages class="text-inherit" />
|
||||
{triggerContent}
|
||||
{#if compact}
|
||||
<span class="sr-only">{triggerContent}</span>
|
||||
{:else}
|
||||
<span class="truncate">{triggerContent}</span>
|
||||
{/if}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
|
||||
@@ -1,40 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { format } from "date-fns";
|
||||
import { AreaChart, Area, LinearGradient } from "layerchart";
|
||||
import { curveCatmullRom } from "d3-shape";
|
||||
import { scaleTime } from "d3-scale";
|
||||
import * as Chart from "$lib/components/ui/chart/index.js";
|
||||
import type { TimestampStatusCount } from "$lib/server/types/db";
|
||||
import { t } from "$lib/stores/i18n";
|
||||
import { ta } from "date-fns/locale";
|
||||
import { ParseLatency } from "$lib/clientTools";
|
||||
import { formatDate } from "$lib/stores/datetime";
|
||||
import { page } from "$app/state";
|
||||
|
||||
interface ChartPoint {
|
||||
date: Date;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: TimestampStatusCount[];
|
||||
data: ChartPoint[];
|
||||
label?: string;
|
||||
height?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { data, height = 128, class: className = "" }: Props = $props();
|
||||
let { data, label = $t("Avg Latency"), height = 128, class: className = "" }: Props = $props();
|
||||
|
||||
// Chart config
|
||||
const chartConfig = {
|
||||
avgLatency: {
|
||||
label: $t("Avg Latency"),
|
||||
let chartConfig = $derived({
|
||||
value: {
|
||||
label: label,
|
||||
color: "var(--chart-1)"
|
||||
}
|
||||
} satisfies Chart.ChartConfig;
|
||||
} satisfies Chart.ChartConfig);
|
||||
|
||||
// Transform data for chart
|
||||
let chartData = $derived.by(() => {
|
||||
if (!data) return [];
|
||||
return data
|
||||
.filter((d) => d.avgLatency > 0)
|
||||
.map((d) => ({
|
||||
date: new Date(d.ts * 1000),
|
||||
avgLatency: d.avgLatency
|
||||
}));
|
||||
});
|
||||
// Filter out zero values
|
||||
let chartData = $derived(data.filter((d) => d.value > 0));
|
||||
</script>
|
||||
|
||||
<div class="{className} ">
|
||||
@@ -44,16 +41,16 @@
|
||||
data={chartData}
|
||||
x="date"
|
||||
xScale={scaleTime()}
|
||||
y="avgLatency"
|
||||
y="value"
|
||||
yDomain={[0, null]}
|
||||
yNice
|
||||
axis="x"
|
||||
grid={false}
|
||||
series={[
|
||||
{
|
||||
key: "avgLatency",
|
||||
label: $t("Avg Latency"),
|
||||
color: "var(--color-avgLatency)"
|
||||
key: "value",
|
||||
label: label,
|
||||
color: "var(--color-value)"
|
||||
}
|
||||
]}
|
||||
props={{
|
||||
@@ -63,7 +60,7 @@
|
||||
line: { class: "stroke-1" }
|
||||
},
|
||||
xAxis: {
|
||||
format: (d: Date) => format(d, "MMM d")
|
||||
format: (d: Date) => $formatDate(d, "MMM d")
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -86,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 ? format(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">
|
||||
|
||||
@@ -10,123 +10,54 @@
|
||||
import { formatDate, formatDuration } from "$lib/stores/datetime";
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
|
||||
interface MaintenanceMonitorImpact {
|
||||
monitor_tag: string;
|
||||
monitor_impact: string;
|
||||
monitor_name: string;
|
||||
monitor_image: string | null;
|
||||
}
|
||||
|
||||
interface Maintenance {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
monitors: MaintenanceMonitorImpact[];
|
||||
start_date_time: number;
|
||||
end_date_time: number;
|
||||
}
|
||||
import { GetInitials } from "$lib/clientTools.js";
|
||||
import type { MaintenanceEventsMonitorList } from "$lib/server/types/db";
|
||||
import { page } from "$app/state";
|
||||
|
||||
interface Props {
|
||||
maintenance: Maintenance;
|
||||
maintenance: MaintenanceEventsMonitorList;
|
||||
class?: string;
|
||||
hideMonitors?: boolean;
|
||||
}
|
||||
|
||||
let { maintenance, class: className = "", hideMonitors = false }: Props = $props();
|
||||
|
||||
const STATUS_STROKE = {
|
||||
UP: "stroke-up",
|
||||
DOWN: "stroke-down",
|
||||
DEGRADED: "stroke-degraded",
|
||||
MAINTENANCE: "stroke-maintenance",
|
||||
NO_DATA: "stroke-muted-foreground"
|
||||
} as const;
|
||||
|
||||
const STATUS_BORDER = {
|
||||
UP: "border-up",
|
||||
DOWN: "border-down",
|
||||
DEGRADED: "border-degraded",
|
||||
MAINTENANCE: "border-maintenance",
|
||||
NO_DATA: "border-muted-foreground"
|
||||
} as const;
|
||||
|
||||
// Get the highest severity impact from monitors array
|
||||
function getHighestImpact(monitors: MaintenanceMonitorImpact[]): keyof typeof STATUS_ICON {
|
||||
const priority: (keyof typeof STATUS_ICON)[] = ["DOWN", "DEGRADED", "MAINTENANCE"];
|
||||
for (const impact of priority) {
|
||||
if (monitors.some((m) => m.monitor_impact === impact)) {
|
||||
return impact;
|
||||
}
|
||||
}
|
||||
return (monitors[0]?.monitor_impact as keyof typeof STATUS_ICON) || "MAINTENANCE";
|
||||
}
|
||||
|
||||
// Get initials from monitor name for avatar fallback
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((word) => word[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
const highestImpact = $derived(getHighestImpact(maintenance.monitors));
|
||||
const Icon = $derived(STATUS_ICON[highestImpact]);
|
||||
const strokeClass = $derived(STATUS_STROKE[highestImpact as keyof typeof STATUS_STROKE] || "stroke-maintenance");
|
||||
const borderClass = $derived(STATUS_BORDER[highestImpact as keyof typeof STATUS_BORDER] || "border-maintenance");
|
||||
const textClass = $derived("text-" + highestImpact.toLowerCase());
|
||||
|
||||
// Check if maintenance is ongoing (current time is between start and end)
|
||||
const isOngoing = $derived(() => {
|
||||
const now = Date.now() / 1000;
|
||||
return now >= maintenance.start_date_time && now <= maintenance.end_date_time;
|
||||
});
|
||||
|
||||
// Truncate description to approximately 2-3 lines
|
||||
function truncateDescription(text: string | null, maxLength: number = 150): string {
|
||||
if (!text) return "";
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength).trim() + "...";
|
||||
}
|
||||
const isEmbedded = page.route.id?.includes("(embed)");
|
||||
const target = isEmbedded ? "_blank" : "_self";
|
||||
</script>
|
||||
|
||||
<Item.Root class="p-0 {className}">
|
||||
<Item.Media>
|
||||
<Icon class="size-6 {strokeClass}" />
|
||||
</Item.Media>
|
||||
<Item.Content>
|
||||
<div class="flex items-center gap-2">
|
||||
<Item.Title>{maintenance.title}</Item.Title>
|
||||
{#if isOngoing()}
|
||||
<Badge variant="outline" class="text-maintenance border-maintenance text-xs">{$t("In Progress")}</Badge>
|
||||
{/if}
|
||||
<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()}">{$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
|
||||
>
|
||||
</Item.Title>
|
||||
</div>
|
||||
|
||||
{#if maintenance.description}
|
||||
<p class="text-muted-foreground mt-1 text-sm">
|
||||
{truncateDescription(maintenance.description)}
|
||||
{maintenance.description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if maintenance.monitors && maintenance.monitors.length > 0 && !hideMonitors}
|
||||
<div class="*:data-[slot=avatar]:ring-background my-2 flex -space-x-2 *:data-[slot=avatar]:ring-2">
|
||||
<div class="flex gap-2">
|
||||
{#each maintenance.monitors as monitor}
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Avatar.Root
|
||||
class="bg-background border-{monitor.monitor_impact.toLowerCase()} size-8 cursor-pointer border-2 transition-transform duration-100 ease-in-out hover:scale-[1.1] hover:border"
|
||||
<Popover.Trigger disabled={isEmbedded}>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="border-{monitor.monitor_impact.toLowerCase()} cursor-pointer rounded-none border-0 border-b px-0 text-sm font-normal"
|
||||
>
|
||||
{#if monitor.monitor_image}
|
||||
<Avatar.Image
|
||||
src={clientResolver(resolve, monitor.monitor_image)}
|
||||
alt={monitor.monitor_name}
|
||||
class="object-cover"
|
||||
/>
|
||||
{/if}
|
||||
<Avatar.Fallback class="text-xs">{getInitials(monitor.monitor_name)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{monitor.monitor_name}
|
||||
</Badge>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-64">
|
||||
<div class="flex flex-col gap-3">
|
||||
@@ -135,7 +66,7 @@
|
||||
{#if monitor.monitor_image}
|
||||
<Avatar.Image src={clientResolver(resolve, monitor.monitor_image)} alt={monitor.monitor_name} />
|
||||
{/if}
|
||||
<Avatar.Fallback>{getInitials(monitor.monitor_name)}</Avatar.Fallback>
|
||||
<Avatar.Fallback>{GetInitials(monitor.monitor_name)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{monitor.monitor_name}</span>
|
||||
@@ -162,31 +93,23 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Item.Description>
|
||||
<div class="mt-2 flex items-center justify-between text-xs font-medium">
|
||||
<div class="rounded-full border px-3 py-2">
|
||||
{$formatDate(maintenance.start_date_time, "PPp")}
|
||||
</div>
|
||||
<div class="relative flex-1 text-center">
|
||||
<div class="absolute top-1/2 right-0 left-0 border-t border-solid"></div>
|
||||
<span class="bg-background relative z-10 px-2 py-1 font-medium"
|
||||
>{$formatDuration(maintenance.start_date_time, maintenance.end_date_time)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="rounded-full border px-3 py-2">
|
||||
{$formatDate(maintenance.end_date_time, "PPp")}
|
||||
</div>
|
||||
</div>
|
||||
<Item.Description
|
||||
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, page.data.dateAndTimeFormat.datePlusTime)}
|
||||
</span>
|
||||
<span class="relative w-full text-center sm:flex-1">
|
||||
<span
|
||||
class="absolute top-0 bottom-0 left-1/2 border-l sm:top-1/2 sm:right-0 sm:bottom-auto sm:left-0 sm:border-t sm:border-l-0"
|
||||
></span>
|
||||
<span class="bg-background relative z-10 rounded-full px-0 py-1 sm:px-2">
|
||||
{$formatDuration(maintenance.start_date_time, maintenance.end_date_time)}
|
||||
</span>
|
||||
</span>
|
||||
<span class="max-w-full rounded-full border px-3 py-2 wrap-break-word">
|
||||
{$formatDate(maintenance.end_date_time, page.data.dateAndTimeFormat.datePlusTime)}
|
||||
</span>
|
||||
</Item.Description>
|
||||
</Item.Content>
|
||||
<Item.Actions>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="cursor-pointer rounded-full shadow-none"
|
||||
href={clientResolver(resolve, `/maintenances/${maintenance.id}`)}
|
||||
size="icon"
|
||||
>
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
</Item.Actions>
|
||||
</Item.Root>
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
import { onMount } from "svelte";
|
||||
import { mode } from "mode-watcher";
|
||||
import { page } from "$app/state";
|
||||
import TrendingUp from "lucide-svelte/icons/trending-up";
|
||||
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 {
|
||||
@@ -32,14 +34,22 @@
|
||||
let hoveredMinute = $state<{ x: number; y: number; data: MinuteData } | null>(null);
|
||||
|
||||
// Layout constants
|
||||
const SQUARE_SIZE = 10;
|
||||
const SQUARE_GAP = 1;
|
||||
const HOUR_LABEL_WIDTH = 0;
|
||||
const SECTION_LABEL_HEIGHT = 20;
|
||||
const SECTION_GAP = 12;
|
||||
const MINUTES_PER_HOUR = 60;
|
||||
const HOURS_PER_SECTION = 6;
|
||||
const ROW_HEIGHT = SQUARE_SIZE + SQUARE_GAP;
|
||||
|
||||
// Responsive square size: fit 60 squares + gaps into the available width
|
||||
let squareSize = $derived.by(() => {
|
||||
if (canvasWidth <= 0) return 10;
|
||||
const available = canvasWidth - HOUR_LABEL_WIDTH;
|
||||
// 60 squares with gaps between them: 60 * size + 59 * gap <= available
|
||||
const size = Math.floor((available - (MINUTES_PER_HOUR - 1) * SQUARE_GAP) / MINUTES_PER_HOUR);
|
||||
return Math.max(3, Math.min(size, 10)); // clamp between 3px and 10px
|
||||
});
|
||||
let rowHeight = $derived(squareSize + SQUARE_GAP);
|
||||
|
||||
// Colors from page data
|
||||
const colorUp = $derived(page.data.siteStatusColors?.UP || "#22c55e");
|
||||
@@ -66,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)) {
|
||||
@@ -95,7 +105,7 @@
|
||||
let height = 0;
|
||||
for (const section of sections) {
|
||||
height += SECTION_LABEL_HEIGHT;
|
||||
height += section.hours.length * ROW_HEIGHT;
|
||||
height += section.hours.length * rowHeight;
|
||||
height += SECTION_GAP;
|
||||
}
|
||||
return Math.max(height, 50);
|
||||
@@ -183,23 +193,23 @@
|
||||
// Draw minute squares
|
||||
for (let minIdx = 0; minIdx < hourMinutes.length; minIdx++) {
|
||||
const minute = hourMinutes[minIdx];
|
||||
const x = HOUR_LABEL_WIDTH + minIdx * (SQUARE_SIZE + SQUARE_GAP);
|
||||
const x = HOUR_LABEL_WIDTH + minIdx * (squareSize + SQUARE_GAP);
|
||||
const y = currentY;
|
||||
|
||||
ctx.fillStyle = getStatusColor(minute.status);
|
||||
ctx.fillRect(x, y, SQUARE_SIZE, SQUARE_SIZE);
|
||||
ctx.fillRect(x, y, squareSize, squareSize);
|
||||
|
||||
// Store rect for hit testing
|
||||
minuteRects.push({
|
||||
x,
|
||||
y,
|
||||
width: SQUARE_SIZE,
|
||||
height: SQUARE_SIZE,
|
||||
width: squareSize,
|
||||
height: squareSize,
|
||||
data: minute
|
||||
});
|
||||
}
|
||||
|
||||
currentY += ROW_HEIGHT;
|
||||
currentY += rowHeight;
|
||||
}
|
||||
|
||||
currentY += SECTION_GAP;
|
||||
@@ -307,11 +317,15 @@
|
||||
{#if hoveredMinute}
|
||||
<div
|
||||
bind:this={tooltipEl}
|
||||
class="bg-foreground text-secondary pointer-events-none absolute z-20 w-max -translate-x-1/2 rounded-md px-2 py-1 text-xs font-medium whitespace-nowrap"
|
||||
class="bg-popover text-popover-foreground border-border pointer-events-none absolute z-20 w-max -translate-x-1/2 rounded-md border px-2 py-1 text-xs font-medium whitespace-nowrap"
|
||||
style={tooltipStyle}
|
||||
>
|
||||
{$formatDate(hoveredMinute.data.timestamp, "HH:mm")} -
|
||||
<span class="text-{hoveredMinute.data.status.toLowerCase()}">{$t(hoveredMinute.data.status)}</span>
|
||||
<span class="text-{hoveredMinute.data.status.toLowerCase()}">
|
||||
{$t(hoveredMinute.data.status)} @ {$formatDate(
|
||||
hoveredMinute.data.timestamp,
|
||||
page.data.dateAndTimeFormat.timeOnly
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from "svelte";
|
||||
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";
|
||||
import { getEndOfDayAtTz } from "$lib/client/datetime";
|
||||
import StatusBarCalendar from "$lib/components/StatusBarCalendar.svelte";
|
||||
import { selectedTimezone } from "$lib/stores/timezone";
|
||||
import type { MonitorBarResponse, BarData } from "$lib/server/api-server/monitor-bar/get.js";
|
||||
import type { MonitorBarResponse } from "$lib/server/api-server/monitor-bar/get.js";
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import { formatDate } from "$lib/stores/datetime";
|
||||
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;
|
||||
prefetchedData?: MonitorBarResponse;
|
||||
prefetchedError?: string;
|
||||
groupChildTags?: string[];
|
||||
days?: number;
|
||||
endOfDayTodayAtTz?: number;
|
||||
compact?: boolean;
|
||||
grid?: boolean;
|
||||
}
|
||||
|
||||
let { tag }: Props = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
let data = $state<MonitorBarResponse | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
let {
|
||||
tag,
|
||||
prefetchedData,
|
||||
prefetchedError,
|
||||
groupChildTags = [],
|
||||
days,
|
||||
endOfDayTodayAtTz,
|
||||
compact = false,
|
||||
grid = false
|
||||
}: Props = $props();
|
||||
let data = $derived(prefetchedData ?? null);
|
||||
let error = $derived(prefetchedError ?? null);
|
||||
let loading = $derived(!data && !error);
|
||||
let showGroupPopover = $derived(
|
||||
groupChildTags.length > 0 && typeof days === "number" && typeof endOfDayTodayAtTz === "number"
|
||||
);
|
||||
|
||||
const STATUS_ICON = {
|
||||
UP: ICONS.UP,
|
||||
@@ -37,90 +56,41 @@
|
||||
MAINTENANCE: "stroke-maintenance",
|
||||
NO_DATA: "stroke-muted-foreground"
|
||||
} as const;
|
||||
|
||||
function formatTimestamp(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
timeZone: $selectedTimezone
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const endOfDayTodayAtTz = getEndOfDayAtTz($selectedTimezone);
|
||||
const response = await fetch(
|
||||
clientResolver(
|
||||
resolve,
|
||||
`/dashboard-apis/monitor-bar?tag=${encodeURIComponent(tag)}&endOfDayTodayAtTz=${endOfDayTodayAtTz}`
|
||||
)
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch monitor data");
|
||||
}
|
||||
data = await response.json();
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch monitor bar data:", e);
|
||||
error = e instanceof Error ? e.message : "Unknown error";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchData();
|
||||
});
|
||||
|
||||
// Re-fetch when timezone changes
|
||||
let initialLoad = true;
|
||||
$effect(() => {
|
||||
// Use untrack to avoid tracking loading/data state changes
|
||||
untrack(() => {
|
||||
if (initialLoad) {
|
||||
initialLoad = false;
|
||||
return;
|
||||
}
|
||||
fetchData();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if loading}
|
||||
<!-- Skeleton loader -->
|
||||
<Item.Root>
|
||||
<Item.Media variant="image">
|
||||
<Skeleton class="size-8 rounded" />
|
||||
</Item.Media>
|
||||
<Item.Content>
|
||||
<Skeleton class="mb-2 h-5 w-40" />
|
||||
<Skeleton class="h-4 w-64" />
|
||||
<Item.Root class="items-start sm:items-center">
|
||||
{#if !compact}
|
||||
<Item.Media variant="image">
|
||||
<Skeleton class="size-8 rounded" />
|
||||
</Item.Media>
|
||||
{/if}
|
||||
<Item.Content class="min-w-0 flex-1">
|
||||
<Skeleton class="mb-2 h-5 w-full" />
|
||||
<Skeleton class="h-4 w-full" />
|
||||
</Item.Content>
|
||||
<Item.Content class="flex-none text-center">
|
||||
<Skeleton class="h-8 w-24" />
|
||||
<Item.Content class="order-3 w-full text-left sm:order-0 sm:w-auto sm:flex-none sm:text-center">
|
||||
<Skeleton class="h-8 w-full" />
|
||||
</Item.Content>
|
||||
<Item.Actions>
|
||||
<Skeleton class="size-9" />
|
||||
</Item.Actions>
|
||||
</Item.Root>
|
||||
<div class="mx-auto flex w-full flex-col gap-1 px-4">
|
||||
<div class="flex justify-end overflow-hidden rounded-full">
|
||||
{#each Array(54) as _, i}
|
||||
<Skeleton
|
||||
class="h-4 w-4 shrink-0 {i === 0 ? 'rounded-tl-full rounded-bl-full' : ''} {i === 53
|
||||
? 'rounded-tr-full rounded-br-full'
|
||||
: ''}"
|
||||
/>
|
||||
{/each}
|
||||
{#if !compact}
|
||||
<div class="mx-auto flex w-full flex-col gap-1 px-4">
|
||||
<div class="flex justify-end overflow-hidden rounded-full">
|
||||
{#each Array(54) as _, i (i)}
|
||||
<Skeleton
|
||||
class="h-4 w-4 shrink-0 {i === 0 ? 'rounded-tl-full rounded-bl-full' : ''} {i === 53
|
||||
? 'rounded-tr-full rounded-br-full'
|
||||
: ''}"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<Skeleton class="h-3 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<Skeleton class="h-3 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if error}
|
||||
<!-- Error state -->
|
||||
<div class="text-destructive p-4 text-center">
|
||||
@@ -129,55 +99,62 @@
|
||||
{:else if data}
|
||||
<!-- Loaded state -->
|
||||
{@const StatusIcon = STATUS_ICON[data.currentStatus]}
|
||||
<Item.Root>
|
||||
<Item.Media variant="image">
|
||||
<Avatar.Root class="size-10">
|
||||
{#if data.image}
|
||||
<Avatar.Image src={clientResolver(resolve, data.image)} alt={data.name} class=" " />
|
||||
{/if}
|
||||
<Avatar.Fallback>{data.name.charAt(1)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
</Item.Media>
|
||||
|
||||
<Item.Content>
|
||||
<Item.Title>{data.name}</Item.Title>
|
||||
<Item.Root class="items-start ">
|
||||
{#if !compact}
|
||||
<Item.Media variant="image" class="hidden sm:block">
|
||||
<Avatar.Root class="size-10">
|
||||
{#if data.image}
|
||||
<Avatar.Image src={clientResolver(resolve, data.image)} alt={data.name} class=" " />
|
||||
{/if}
|
||||
<Avatar.Fallback>{GetInitials(data.name)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
</Item.Media>
|
||||
{/if}
|
||||
<Item.Content class="min-w-0 flex-1">
|
||||
<Item.Title class="w-full truncate">
|
||||
<a class="hover:underline" href={clientResolver(resolve, `/monitors/${tag}`)}>{data.name}</a>
|
||||
</Item.Title>
|
||||
{#if data.description}
|
||||
<Item.Description>{data.description}</Item.Description>
|
||||
<Item.Description class="line-clamp-2 wrap-break-word">{data.description}</Item.Description>
|
||||
{/if}
|
||||
</Item.Content>
|
||||
|
||||
<Item.Content class="flex-none text-center">
|
||||
<Item.Content class="order-3 w-full text-left sm:order-0 sm:w-auto sm:flex-none sm:text-center">
|
||||
<Item.Title class="items-start text-2xl">
|
||||
<StatusIcon class="{STATUS_STROKE[data.currentStatus]} mt-1.5" />
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span>{data.uptime}%</span>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
<StatusIcon class="{STATUS_STROKE[data.currentStatus]} {grid ? 'mt-1 size-5' : 'mt-1.5 size-6'}" />
|
||||
<div class="flex flex-col items-start gap-1 sm:items-end">
|
||||
<span class={grid ? "text-base sm:text-lg" : "text-lg sm:text-xl"}>{data.uptime}%</span>
|
||||
<span class="text-muted-foreground text-right text-xs">
|
||||
{data.avgLatency}
|
||||
</span>
|
||||
</div>
|
||||
</Item.Title>
|
||||
</Item.Content>
|
||||
<Item.Actions>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
class="cursor-pointer rounded-full shadow-none"
|
||||
href={clientResolver(resolve, `/monitors/${tag}`)}
|
||||
>
|
||||
<ICONS.ARROW_RIGHT />
|
||||
</Button>
|
||||
</Item.Actions>
|
||||
</Item.Root>
|
||||
<div class="mx-auto flex w-full flex-col gap-1 px-4">
|
||||
<StatusBarCalendar data={data.uptimeData} monitorTag={tag} barHeight={40} radius={8} />
|
||||
<div class="flex justify-between">
|
||||
<p class="text-muted-foreground text-xs font-medium">
|
||||
{formatTimestamp(data.fromTimeStamp)}
|
||||
</p>
|
||||
<p class="text-muted-foreground text-xs font-medium">
|
||||
{formatTimestamp(data.toTimeStamp)}
|
||||
</p>
|
||||
{#if !compact}
|
||||
<div class="mx-auto flex w-full flex-col gap-1 px-4">
|
||||
<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), 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), page.data.dateAndTimeFormat.dateOnly)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showGroupPopover}
|
||||
<div class="mt-2 flex justify-center gap-2 px-4">
|
||||
<GroupMonitorPopover
|
||||
tags={groupChildTags}
|
||||
days={days as number}
|
||||
endOfDayTodayAtTz={endOfDayTodayAtTz as number}
|
||||
>
|
||||
{$t("Included Monitors (%count)", { count: String(groupChildTags.length) })}
|
||||
</GroupMonitorPopover>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -2,29 +2,27 @@
|
||||
import { t } from "$lib/stores/i18n";
|
||||
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
|
||||
|
||||
import { format } from "date-fns";
|
||||
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 Clock from "@lucide/svelte/icons/clock";
|
||||
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 { IncidentForMonitorList, MaintenanceEventsMonitorList } from "$lib/server/types/db";
|
||||
import type { IncidentForMonitorListWithComments, MaintenanceEventsMonitorList } from "$lib/server/types/db";
|
||||
import { formatDate } from "$lib/stores/datetime";
|
||||
import trackEvent from "$lib/beacon";
|
||||
|
||||
interface DayDetailData {
|
||||
minutes: Array<{
|
||||
timestamp: number;
|
||||
@@ -57,9 +55,10 @@
|
||||
let incidentsLoading = $state(false);
|
||||
let maintenancesLoading = $state(false);
|
||||
let activeView = $state<"status" | "latency" | "incidents" | "maintenances">("status");
|
||||
let lastTrackedView = $state<"status" | "latency" | "incidents" | "maintenances">("status");
|
||||
let dayDetailData = $state<DayDetailData | null>(null);
|
||||
let dayLatencyData = $state<DayLatencyData | null>(null);
|
||||
let dayIncidentsData = $state<IncidentForMonitorList[]>([]);
|
||||
let dayIncidentsData = $state<IncidentForMonitorListWithComments[]>([]);
|
||||
let dayMaintenancesData = $state<MaintenanceEventsMonitorList[]>([]);
|
||||
// Chart config for latency
|
||||
const chartConfig = {
|
||||
@@ -186,14 +185,6 @@
|
||||
maintenancesLoading = false;
|
||||
}
|
||||
}
|
||||
// Format date for display
|
||||
function formatDate(timestamp: number): string {
|
||||
return format(new Date(timestamp * 1000), "EEEE, MMMM do, yyyy");
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
return format(new Date(timestamp * 1000), "HH:mm");
|
||||
}
|
||||
|
||||
// Fetch data when dialog opens with a new day
|
||||
$effect(() => {
|
||||
@@ -204,28 +195,48 @@
|
||||
fetchDayMaintenances();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (open && activeView !== lastTrackedView) {
|
||||
trackEvent("monitor_day_tab_changed", { view: activeView, monitorTag });
|
||||
lastTrackedView = activeView;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Overlay class="backdrop-blur-[2px]" />
|
||||
<Dialog.Content class="max-h-[80vh] overflow-y-auto rounded-3xl sm:max-w-[46.5rem]">
|
||||
<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">
|
||||
<Activity class="h-5 w-5" />
|
||||
{selectedDay ? formatDate(selectedDay.timestamp) : ""}
|
||||
<Dialog.Title class="flex items-center gap-2 text-base sm:text-lg">
|
||||
<span class="truncate">
|
||||
{selectedDay ? $formatDate(new Date(selectedDay.timestamp * 1000), page.data.dateAndTimeFormat.dateOnly) : ""}
|
||||
</span>
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>{$t("Minute-by-minute status data for this day")}</Dialog.Description>
|
||||
<Dialog.Description class="text-xs sm:text-sm"
|
||||
>{$t("Minute-by-minute status data for this day")}</Dialog.Description
|
||||
>
|
||||
</Dialog.Header>
|
||||
|
||||
<Tabs.Root value={activeView} class="bg-background ktabs w-full overflow-hidden rounded-3xl border">
|
||||
<Tabs.List class="h-auto w-full justify-end rounded-none px-2 py-2">
|
||||
<Tabs.Trigger value="status" class="rounded-3xl py-2">{$t("Status")}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="latency" class="rounded-3xl py-2">{$t("Latency")}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="incidents" class="rounded-3xl py-2">{$t("Incidents")}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="maintenances" class="rounded-3xl py-2">{$t("Maintenances")}</Tabs.Trigger>
|
||||
<Tabs.Root value={activeView} class="bg-background ktabs w-full overflow-hidden rounded-3xl border">
|
||||
<Tabs.List
|
||||
class="scrollbar-hidden h-auto w-full justify-start gap-1 overflow-x-auto rounded-none px-2 py-2 sm:justify-end"
|
||||
>
|
||||
<Tabs.Trigger value="status" class="shrink-0 rounded-3xl px-2 py-1.5 text-xs sm:px-3 sm:py-2 sm:text-sm"
|
||||
>{$t("Status")}</Tabs.Trigger
|
||||
>
|
||||
<Tabs.Trigger value="latency" class="shrink-0 rounded-3xl px-2 py-1.5 text-xs sm:px-3 sm:py-2 sm:text-sm"
|
||||
>{$t("Latency")}</Tabs.Trigger
|
||||
>
|
||||
<Tabs.Trigger value="incidents" class="shrink-0 rounded-3xl px-2 py-1.5 text-xs sm:px-3 sm:py-2 sm:text-sm"
|
||||
>{$t("Incidents")}</Tabs.Trigger
|
||||
>
|
||||
<Tabs.Trigger value="maintenances" class="shrink-0 rounded-3xl px-2 py-1.5 text-xs sm:px-3 sm:py-2 sm:text-sm"
|
||||
>{$t("Maintenances")}</Tabs.Trigger
|
||||
>
|
||||
</Tabs.List>
|
||||
<!-- status view -->
|
||||
<Tabs.Content value="status" class="p-4">
|
||||
<Tabs.Content value="status" class="p-2 sm:p-4">
|
||||
{#if loading}
|
||||
<!-- Loading state -->
|
||||
<div class="space-y-4 py-4">
|
||||
@@ -247,7 +258,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="latency" class="p-4">
|
||||
<Tabs.Content value="latency" class="p-2 sm:p-4">
|
||||
{#if latencyLoading}
|
||||
<!-- Loading state -->
|
||||
<div class="space-y-4 py-4">
|
||||
@@ -276,7 +287,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Latency chart -->
|
||||
<Chart.Container config={chartConfig} class="min-h-64 w-full">
|
||||
<Chart.Container config={chartConfig} class="min-h-48 w-full sm:min-h-64">
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
x="date"
|
||||
@@ -300,7 +311,7 @@
|
||||
line: { class: "stroke-1" }
|
||||
},
|
||||
xAxis: {
|
||||
format: (d: Date) => format(d, "HH:mm")
|
||||
format: (d: Date) => $formatDate(d, page.data.dateAndTimeFormat.timeOnly)
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -325,12 +336,14 @@
|
||||
class="mt-0.5 size-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
|
||||
></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 ? format(item.payload.date, "HH:mm") : ""}</span
|
||||
>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{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>
|
||||
@@ -351,7 +364,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="incidents" class="p-4">
|
||||
<Tabs.Content value="incidents" class="p-2 sm:p-4">
|
||||
{#if incidentsLoading}
|
||||
<!-- Loading state -->
|
||||
<div class="space-y-4 py-4">
|
||||
@@ -365,19 +378,10 @@
|
||||
<div class="space-y-4">
|
||||
<!-- Incident list -->
|
||||
<div>
|
||||
<div class="text-muted-foreground mb-2 flex items-center justify-between text-sm font-medium">
|
||||
<p class="">{$t("Incidents")}</p>
|
||||
<div class="flex items-center gap-1">
|
||||
<Clock class="h-3 w-3" />
|
||||
{dayIncidentsData.length}
|
||||
{$t("Total")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scrollbar-hidden flex max-h-100 flex-col gap-4 overflow-y-auto">
|
||||
{#each dayIncidentsData as incident (incident.id)}
|
||||
<div class="border-b pb-5 last:border-b-0">
|
||||
<IncidentItem {incident} hideMonitors={true} />
|
||||
<IncidentItem {incident} hideMonitors={true} showComments={false} showSummary={false} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -389,7 +393,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="maintenances" class="p-4">
|
||||
<Tabs.Content value="maintenances" class="p-2 sm:p-4">
|
||||
{#if maintenancesLoading}
|
||||
<!-- Loading state -->
|
||||
<div class="space-y-4 py-4">
|
||||
@@ -400,24 +404,12 @@
|
||||
<Skeleton class="h-64 w-full" />
|
||||
</div>
|
||||
{:else if dayMaintenancesData.length > 0}
|
||||
<div class="space-y-4">
|
||||
<!-- Maintenance list -->
|
||||
<div>
|
||||
<div class="text-muted-foreground mb-2 flex items-center justify-between text-sm font-medium">
|
||||
<p class="">{$t("Maintenances")}</p>
|
||||
<div class="flex items-center gap-1">
|
||||
<Clock class="h-3 w-3" />
|
||||
{dayMaintenancesData.length} Total
|
||||
</div>
|
||||
<div class="scrollbar-hidden flex max-h-100 flex-col gap-4 space-y-4 overflow-y-auto">
|
||||
{#each dayMaintenancesData as maintenance (maintenance.id)}
|
||||
<div class="border-b pb-5 last:border-b-0">
|
||||
<MaintenanceItem {maintenance} hideMonitors={true} />
|
||||
</div>
|
||||
<div class="scrollbar-hidden flex max-h-100 flex-col gap-4 overflow-y-auto">
|
||||
{#each dayMaintenancesData as maintenance (maintenance.id)}
|
||||
<div class="border-b pb-5 last:border-b-0">
|
||||
<MaintenanceItem {maintenance} hideMonitors={true} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="py-8 text-center">
|
||||
|
||||
@@ -1,64 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { resolve } from "$app/paths";
|
||||
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";
|
||||
import ChevronDown from "@lucide/svelte/icons/chevron-down";
|
||||
import ArrowUp from "@lucide/svelte/icons/arrow-up";
|
||||
import type { MonitorBarResponse } from "$lib/server/api-server/monitor-bar/get";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { t } from "$lib/stores/i18n";
|
||||
import { selectedTimezone } from "$lib/stores/timezone";
|
||||
import { getEndOfDayAtTz } from "$lib/client/datetime";
|
||||
import { formatDate } from "$lib/stores/datetime";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import { requestMonitorBar, clearMonitorBarCache } from "$lib/client/monitor-bar-client";
|
||||
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;
|
||||
class?: string;
|
||||
maxDays?: number;
|
||||
groupTags?: string[];
|
||||
}
|
||||
|
||||
let { monitorTag, class: className = "" }: Props = $props();
|
||||
let { monitorTag, class: className = "", maxDays = 90, groupTags = [] }: Props = $props();
|
||||
|
||||
// State
|
||||
let loading = $state(true);
|
||||
let overviewData = $state<MonitorBarResponse | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Day range options
|
||||
const dayOptions = [
|
||||
{ days: 90, text: $t("90 Days") },
|
||||
{ days: 30, text: $t("30 Days") },
|
||||
{ days: 7, text: $t("7 Days") },
|
||||
{ days: 1, text: $t("24 Hours") }
|
||||
];
|
||||
// All possible day range options (ascending)
|
||||
const allDayOptions = [1, 7, 14, 30, 60, 90];
|
||||
|
||||
// Filter options up to maxDays, always include maxDays itself
|
||||
let dayOptions = $derived.by(() => {
|
||||
const filtered = allDayOptions.filter((d) => d <= maxDays);
|
||||
if (!filtered.includes(maxDays)) {
|
||||
filtered.push(maxDays);
|
||||
}
|
||||
// Sort descending for dropdown display
|
||||
filtered.sort((a, b) => b - a);
|
||||
return filtered.map((d) => ({
|
||||
days: d,
|
||||
text: `${d} ${d === 1 ? $t("Day") : $t("Days")}`
|
||||
}));
|
||||
});
|
||||
|
||||
// Default to maxDays (first item since sorted descending)
|
||||
let selectedDayIndex = $state(0);
|
||||
let selectedDays = $derived(dayOptions[selectedDayIndex]?.days ?? maxDays);
|
||||
let endOfDayTodayAtTz = $derived(getEndOfDayAtTz($selectedTimezone));
|
||||
|
||||
// Latency metric toggle: "average" | "maximum" | "minimum"
|
||||
let latencyMetric = $state("average");
|
||||
|
||||
// Display values from API response (already formatted as strings)
|
||||
let displayUptime = $derived(overviewData?.uptime ?? "--");
|
||||
|
||||
let displayAvgLatency = $derived(overviewData?.avgLatency ?? "--");
|
||||
let displayMaxLatency = $derived(overviewData?.maxLatency ?? "--");
|
||||
let displayMinLatency = $derived(overviewData?.minLatency ?? "--");
|
||||
|
||||
// Data for calendar/chart comes directly from API
|
||||
let displayData = $derived(overviewData?.uptimeData ?? []);
|
||||
|
||||
// Latency metric label map
|
||||
const metricLabels: Record<string, string> = {
|
||||
average: $t("Avg Latency"),
|
||||
maximum: $t("Max Latency"),
|
||||
minimum: $t("Min Latency")
|
||||
};
|
||||
|
||||
// Transform uptimeData into chart-ready points based on selected metric
|
||||
let latencyChartData = $derived.by(() => {
|
||||
if (!displayData) return [];
|
||||
return displayData.map((d) => ({
|
||||
date: new Date(d.ts * 1000),
|
||||
value: latencyMetric === "maximum" ? d.maxLatency : latencyMetric === "minimum" ? d.minLatency : d.avgLatency
|
||||
}));
|
||||
});
|
||||
|
||||
let latencyChartLabel = $derived(metricLabels[latencyMetric] ?? metricLabels.average);
|
||||
|
||||
// Fetch data with days parameter
|
||||
async function fetchData(days: number) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const endOfDayTodayAtTz = getEndOfDayAtTz($selectedTimezone);
|
||||
const url = `?tag=${monitorTag}&endOfDayTodayAtTz=${endOfDayTodayAtTz}&days=${days}`;
|
||||
const response = await fetch(clientResolver(resolve, "/dashboard-apis/monitor-bar") + url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch monitor data");
|
||||
}
|
||||
|
||||
overviewData = await response.json();
|
||||
overviewData = await requestMonitorBar(monitorTag, days, endOfDayTodayAtTz);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch monitor data:", e);
|
||||
error = e instanceof Error ? e.message : "Failed to load data";
|
||||
@@ -71,6 +105,8 @@
|
||||
function handleDayChange(index: number) {
|
||||
if (index !== selectedDayIndex) {
|
||||
selectedDayIndex = index;
|
||||
// Evict cache so the new range fetches fresh data
|
||||
clearMonitorBarCache();
|
||||
fetchData(dayOptions[index].days);
|
||||
}
|
||||
}
|
||||
@@ -118,7 +154,7 @@
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Label>{$t("Select Range")}</DropdownMenu.Label>
|
||||
<DropdownMenu.Group>
|
||||
{#each dayOptions as option, i}
|
||||
{#each dayOptions as option, i (option.days)}
|
||||
<DropdownMenu.Item
|
||||
class="cursor-pointer text-xs {selectedDayIndex === i ? 'bg-secondary' : ''}"
|
||||
onclick={() => handleDayChange(i)}
|
||||
@@ -157,37 +193,83 @@
|
||||
<div class="flex gap-4">
|
||||
<div class="flex flex-1 flex-col items-start gap-1">
|
||||
<p class="text-2xl font-semibold">{displayUptime}%</p>
|
||||
<p class="text-muted-foreground text-sm">{$t("Uptime")}</p>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col items-end gap-1">
|
||||
<p class="text-2xl font-semibold">{displayAvgLatency}</p>
|
||||
<p class="text-muted-foreground text-sm">{$t("Avg Latency")}</p>
|
||||
<p class="text-muted-foreground text-sm font-medium">{$t("Uptime")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Bar Calendar -->
|
||||
<StatusBarCalendar data={displayData} {monitorTag} barHeight={40} radius={8} />
|
||||
<div class="flex flex-col gap-1">
|
||||
<StatusBarCalendar data={displayData} {monitorTag} barHeight={40} radius={8} />
|
||||
|
||||
<!-- Date labels -->
|
||||
<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")}
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-muted-foreground text-xs font-medium">
|
||||
{#if displayData.length > 0}
|
||||
{$formatDate(displayData[displayData.length - 1].ts, "d MMM yyyy")}
|
||||
{/if}
|
||||
</p>
|
||||
<!-- Date labels -->
|
||||
<div class="flex justify-between">
|
||||
<p class="text-muted-foreground text-xs font-medium">
|
||||
{#if displayData.length > 0}
|
||||
{$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, page.data.dateAndTimeFormat.dateOnly)}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add group GroupMonitorPopover here -->
|
||||
{#if groupTags.length > 0}
|
||||
<div class="flex justify-center">
|
||||
<GroupMonitorPopover tags={groupTags} days={selectedDays} {endOfDayTodayAtTz}>
|
||||
{$t("Included Monitors (%count)", { count: String(groupTags.length) })}
|
||||
<ArrowUp class="size-3" />
|
||||
</GroupMonitorPopover>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Latency Chart -->
|
||||
<div class="pt-2">
|
||||
<p class="text-muted-foreground mb-2 text-xs font-medium">
|
||||
<p class="text-muted-foreground mb-2 text-sm font-medium">
|
||||
{$t("Latency Trend")}
|
||||
<Popover.Root>
|
||||
<Popover.Trigger class="text-foreground cursor-pointer underline">{latencyChartLabel}</Popover.Trigger>
|
||||
<Popover.Content class="flex w-fit flex-col gap-2">
|
||||
<p class="text-muted-foreground text-xs font-medium">
|
||||
{$t("Select latency metric to display")}
|
||||
</p>
|
||||
<ToggleGroup.Root
|
||||
type="single"
|
||||
spacing={2}
|
||||
size="sm"
|
||||
value={latencyMetric}
|
||||
onValueChange={(v) => {
|
||||
if (v) latencyMetric = v;
|
||||
}}
|
||||
class="flex justify-between"
|
||||
>
|
||||
<ToggleGroup.Item value="average" aria-label="Average latency">{$t("Avg Latency")}</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="maximum" aria-label="Maximum latency">{$t("Max Latency")}</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="minimum" aria-label="Minimum latency">{$t("Min Latency")}</ToggleGroup.Item>
|
||||
</ToggleGroup.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</p>
|
||||
<LatencyTrendChart data={displayData} height={128} />
|
||||
|
||||
<!-- Latency Stats -->
|
||||
<div class="mb-3 flex justify-between gap-4">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<p class="text-lg font-semibold">{displayMinLatency}</p>
|
||||
<p class="text-muted-foreground text-xs">{$t("Minimum Latency")}</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<p class="text-lg font-semibold">{displayAvgLatency}</p>
|
||||
<p class="text-muted-foreground text-xs">{$t("Average Latency")}</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<p class="text-lg font-semibold">{displayMaxLatency}</p>
|
||||
<p class="text-muted-foreground text-xs">{$t("Maximum Latency")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LatencyTrendChart data={latencyChartData} label={latencyChartLabel} height={128} />
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<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, 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 {
|
||||
monitorTags?: string[];
|
||||
compact?: boolean;
|
||||
eventsPath: string;
|
||||
}
|
||||
|
||||
let { monitorTags = [], compact = true, eventsPath = "" }: Props = $props();
|
||||
let open = $state(false);
|
||||
let notifications = $state<NotificationEvent[]>([]);
|
||||
let loading = $state(false);
|
||||
|
||||
const defaultEventsPath = $derived(`/events/${format(new Date(), "MMMM-yyyy")}`);
|
||||
|
||||
const resolvedEventsPath = $derived.by(() => {
|
||||
const finalEventsPath = eventsPath || defaultEventsPath;
|
||||
if (page.data?.globalPageVisibilitySettings?.forceExclusivity) {
|
||||
const currentPagePath = page.params?.page_path?.trim();
|
||||
return currentPagePath ? `/${currentPagePath}${finalEventsPath}` : finalEventsPath;
|
||||
}
|
||||
return finalEventsPath;
|
||||
});
|
||||
|
||||
async function fetchNotifications() {
|
||||
loading = true;
|
||||
try {
|
||||
const query = monitorTags.length > 0 ? `?tags=${encodeURIComponent(monitorTags.join(","))}` : "";
|
||||
const response = await fetch(clientResolver(resolve, "/dashboard-apis/notifications") + query);
|
||||
if (response.ok) {
|
||||
const payload = await response.json();
|
||||
notifications = payload.notifications || [];
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchNotifications();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="outline"
|
||||
size={compact ? "icon-sm" : "sm"}
|
||||
class="bg-background/80 dark:bg-background/70 border-foreground/10 relative mr-2 rounded-full border text-xs shadow-none backdrop-blur-md"
|
||||
aria-label={$t("Notifications")}
|
||||
>
|
||||
{#if loading}
|
||||
<Spinner class="h-4 w-4" />
|
||||
{:else}
|
||||
<ICONS.Bell class="h-4 w-4" />
|
||||
{#if notifications.length > 0}
|
||||
<span
|
||||
class="bg-accent-foreground text-accent absolute -top-1 -right-1 min-w-4 rounded-full px-1 text-[10px] leading-4"
|
||||
>
|
||||
{notifications.length}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
align="end"
|
||||
class="bg-background/30 supports-backdrop-filter:bg-background/20 w-96 rounded-3xl border p-0 shadow-2xl backdrop-blur-2xl"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h4 class="text-sm font-semibold">{$t("Events")}</h4>
|
||||
<Button variant="outline" href={clientResolver(resolve, resolvedEventsPath)} size="icon-sm" class="rounded-btn">
|
||||
<Calendar class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if notifications.length === 0}
|
||||
<div class="text-muted-foreground px-4 py-6 text-sm">
|
||||
{$t("No events to show")}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="scrollbar-hidden max-h-96 overflow-y-auto">
|
||||
{#each notifications as item, i (`${item.eventType}-${item.eventURL}-${item.eventDate}-${i}`)}
|
||||
<a
|
||||
href={clientResolver(resolve, item.eventURL)}
|
||||
class="hover:bg-muted/60 block border-b px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div class="my-0.5 flex items-center justify-between gap-2 text-xs">
|
||||
<span class="text-muted-foreground text-[11px] uppercase">{$t(item.eventType)}</span>
|
||||
<span class="text-{item.eventStatus.toLowerCase()}">{$t(item.eventStatus)}</span>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="line-clamp-2 text-sm">{item.eventTitle}</p>
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-1 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span>{$formatDate(item.eventDate, page.data.dateAndTimeFormat.datePlusTime)}</span>
|
||||
|
||||
<span>•</span>
|
||||
<span>{$formatDuration(item.eventStartDateTime, item.eventEndDateTime, $t("Ongoing"))}</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user