mirror of
https://github.com/rajnandan1/kener.git
synced 2026-08-07 15:27:13 +00:00
Compare commits
392 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1750e2a341 | |||
| 560c87219b | |||
| 94e24eec04 | |||
| 15680a58aa | |||
| 59f0eaef27 | |||
| 8362a73058 | |||
| 52f8c50f50 | |||
| 60868d55ca | |||
| f7e657ee95 | |||
| 63e5ec2886 | |||
| 2aef97c1ed | |||
| 51b2da97e0 | |||
| 50bddcd9a3 | |||
| bd36533b05 | |||
| 17500a0b43 | |||
| 0050cd810b | |||
| db6cb6cf7d | |||
| babeeb75b2 | |||
| e0187605e7 | |||
| e2861f1e59 | |||
| 01aa4d9984 | |||
| 555372f175 | |||
| bd5fd409d1 | |||
| f61d82e13f | |||
| f39588fff7 | |||
| 0716f271df | |||
| 1085c3e561 | |||
| 93907e6d96 | |||
| d03fd63c64 | |||
| 3ff43af787 | |||
| 7f2fef8e8e | |||
| 7658170865 | |||
| b1f8a505a5 | |||
| 104da58646 | |||
| ed6e97e8c9 | |||
| 0b25274874 | |||
| bb8ab41bfe | |||
| b43a5fb343 | |||
| 554caa5018 | |||
| ffe7403043 | |||
| a26d0ece59 | |||
| a4277f7ed0 | |||
| f75aaf9cef | |||
| 4e1ecf41ee | |||
| f23fb5313c | |||
| 18489c5339 | |||
| 4044bae26d | |||
| 02686caa78 | |||
| 797aef80d6 | |||
| a8841ad8a3 | |||
| 0b2cd5fc8a | |||
| 9d349716e5 | |||
| 92d068ef49 | |||
| c6e3620151 | |||
| d92165d0f8 | |||
| a56bbead8d | |||
| db3fb923f0 | |||
| bb9d88a095 | |||
| 912da6b8f4 | |||
| 3b07623346 | |||
| 702ceca9b0 | |||
| 21f2433919 | |||
| 4e7791b104 | |||
| f79c24c80d | |||
| 087c2f25fb | |||
| 36f2ae1f69 | |||
| dcd830eb82 | |||
| 80637fd4aa | |||
| a7f0072f32 | |||
| a39e676a23 | |||
| fbc926036e | |||
| 5b508d19bc | |||
| 589281ce13 | |||
| 36aec8c519 | |||
| 978bfb05f2 | |||
| 3ffd0538a1 | |||
| 932a05a9ee | |||
| 15c62fa40f | |||
| a5afd38520 | |||
| 5138f7fb6e | |||
| 1bed0538db | |||
| bd582eaad3 | |||
| 5803ccadca | |||
| 4f16b06a0f | |||
| fad23e2a01 | |||
| 4b84e47d89 | |||
| 9e020c10f4 | |||
| 6fd47963ec | |||
| df9b36b242 | |||
| f9fe74ef94 | |||
| 823ea6eeb7 | |||
| 6c7606d3b0 | |||
| d2e9437cfa | |||
| a549eb5d1b | |||
| 648f8180e7 | |||
| 9afb8947ac | |||
| 0c8338e2a5 | |||
| df94755c6b | |||
| 204419fccb | |||
| a973494bc8 | |||
| ba459e61ad | |||
| f92fc8ff73 | |||
| 91cb4850ca | |||
| fb7939a4dc | |||
| 1f352591a4 | |||
| 0085621900 | |||
| 0eb789d89a | |||
| 7f21b27bb8 | |||
| 7abd6788de | |||
| 681b116e09 | |||
| 04b1d0716b | |||
| 668f12a0eb | |||
| 394149a3d6 | |||
| 57c8ff28ab | |||
| 5b29af9e74 | |||
| 9f5b90cb93 | |||
| e4f001acf7 | |||
| 604dbccaea | |||
| 6b7d0335b7 | |||
| b55dd29f02 | |||
| dcd7918a08 | |||
| 38e3a538ef | |||
| 19c2275a7b | |||
| a53251454e | |||
| 8f39a0f649 | |||
| 674c310b67 | |||
| a550afca60 | |||
| e64d955ecb | |||
| 57fda67ed7 | |||
| 6d5a42aeb2 | |||
| 5d9160c36d | |||
| b465f48c6e | |||
| 8c16d99f55 | |||
| fdedbb78cf | |||
| 416beca1a6 | |||
| e261d3620e | |||
| 1089133b97 | |||
| 90de4ae4cb | |||
| 4e263284c8 | |||
| 343368631f | |||
| f5f40e1438 | |||
| 74f6601f5d | |||
| c4e03c1a83 | |||
| bb9124db0d | |||
| ce52506c95 | |||
| f20b74cb36 | |||
| f3a5839aae | |||
| 0896aed91e | |||
| d8ae54da53 | |||
| 66bf012c87 | |||
| 08fc54d2ea | |||
| b36e68f7c8 | |||
| 045f32b7ce | |||
| 7ae869d98a | |||
| efb04a4238 | |||
| e6e586f6de | |||
| e13d7fb1f4 | |||
| 23b0bae018 | |||
| e581346f84 | |||
| 555fd3a8a2 | |||
| 7a29d2f2ca | |||
| 7a3fe20083 | |||
| c16c119d65 | |||
| 048a12899d | |||
| 5fd0e691b3 | |||
| 28932f8df5 | |||
| 4da29ac4e1 | |||
| 396fc5e3c3 | |||
| af8daf98f6 | |||
| 2e91f90057 | |||
| 3213ab8efa | |||
| f2308d9bd1 | |||
| 623465ff50 | |||
| 7c224b3886 | |||
| 166073fbd4 | |||
| 373cbd5917 | |||
| 2c27859197 | |||
| bb5d58b675 | |||
| caca29d354 | |||
| 35f0adb235 | |||
| 8bcff45d87 | |||
| 3ad256a626 | |||
| c4b9181a0a | |||
| 1ba40fadf6 | |||
| 20f1481a01 | |||
| 828747876b | |||
| cbe0ea683f | |||
| 561864c625 | |||
| 3abdb5c17a | |||
| f2ef19e3d8 | |||
| 78b18a59ec | |||
| c28581b51d | |||
| f9486927de | |||
| 86645d9ea3 | |||
| feea1d76cd | |||
| 2e95f31d93 | |||
| 18cf8f51b7 | |||
| 9af842ebc0 | |||
| 3d157f1c20 | |||
| 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 | |||
| 4ff1604044 | |||
| be0745f2f7 | |||
| cdc0de9405 | |||
| 47c00c2837 | |||
| baa9449b8c | |||
| 1c05e2d7da | |||
| a20125a6ed | |||
| 845132fc58 | |||
| 038c52ad7d | |||
| 6aa43fa261 | |||
| 6ed95ed8dc | |||
| ba41c6f2a7 | |||
| 96fd8d0874 | |||
| 9a0ca6cde4 | |||
| 2d7d6a3d77 | |||
| 08f72e5aeb | |||
| 52c76fc664 | |||
| 4d0e1357a6 | |||
| e92c8a0975 | |||
| 331cd5b59a | |||
| 601186dcc0 | |||
| 3295b7b081 | |||
| 34605fe3b0 | |||
| 045ce12c8b | |||
| 98c723cfa5 | |||
| 24872d2dc5 | |||
| 00511da24c | |||
| d851610432 | |||
| 6c15d48b58 | |||
| 8bd26cca92 | |||
| 31655679e6 | |||
| accdd7698c | |||
| 8f187aa917 | |||
| c77985fa92 | |||
| 2749b8017c | |||
| c4aff9f81b | |||
| c4739e0257 | |||
| 93f91ab332 | |||
| 8a0e1e7a61 | |||
| 463d1c8ddf | |||
| 9c8d4bcb89 | |||
| d300f3cdae | |||
| b14a5c9f5f | |||
| 2abb266e3b | |||
| e714fe8fe7 | |||
| b7023b7b1f | |||
| 3936edb017 | |||
| 71a9157abf | |||
| 3b571f404c | |||
| 6ff6e595a6 | |||
| 28bfd8cd17 | |||
| af829fa73e | |||
| d0d8e60a8f | |||
| c4f094572d | |||
| b4aeb5134b | |||
| 4608b03659 | |||
| b51fc19671 | |||
| 2dce9814ac | |||
| 9f2ab70ded | |||
| bc7c23c1f0 | |||
| d6b68e3638 | |||
| 07091110f5 | |||
| 6b88cf11a1 | |||
| 0149b3e61a | |||
| 0d31187ab4 | |||
| 9187fdd399 | |||
| d5dba78c7c | |||
| bb8e6229d3 | |||
| 5dea324626 | |||
| 0a616c1c94 | |||
| a188696a8e | |||
| c69d9fdf4e | |||
| 83e807ac24 | |||
| 5dea28e6a6 | |||
| a1bba1e69c | |||
| b056266adf | |||
| 249c9e8e4f | |||
| ebc30b674f | |||
| bf079798d3 | |||
| 480cf64738 | |||
| 65f039b7bd | |||
| 5c7b62e9f7 | |||
| 5e6e891ea6 | |||
| 82c904608b | |||
| f5260ceb86 | |||
| abb32fdd6f | |||
| 97aef5c5f5 | |||
| c3eb8ae7f4 | |||
| 72f74f9164 | |||
| 958676e5a7 | |||
| 51134670e5 | |||
| ecea2b220d | |||
| 212f4d5e7a | |||
| 2edb09bd13 | |||
| 6b9f073cb7 | |||
| 9f933a0f1c | |||
| 8a6f721bb4 | |||
| eeadcca6ed | |||
| 8e89c10906 | |||
| 833d16351c | |||
| 0c10552fa7 | |||
| 443afe2f6e | |||
| db48d98da7 | |||
| bf3150434a | |||
| 71c4bd26d2 | |||
| ca4f7040e8 | |||
| 1cfa861d7e | |||
| 007baefe80 | |||
| 7d77f4d996 | |||
| 3d5a62c085 | |||
| 24ffef997b | |||
| 7d5275152c | |||
| 93167c2358 | |||
| 734d3ad280 | |||
| 753668dfa5 | |||
| 351817338d | |||
| 7c12218489 | |||
| 7c71298227 | |||
| 167db6d7e6 | |||
| 74834f3559 | |||
| e13ca5c639 | |||
| a51806d62b | |||
| 57238dfb72 | |||
| a2be642af0 | |||
| aec2d23c2f | |||
| 11a11162da | |||
| 12a7be497e | |||
| 9b69da66a5 | |||
| 3b05ca73e5 | |||
| 236d7ea585 |
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: documentation-writer
|
||||
description: Specialized skill for creating and editing high-quality Kener documentation. MUST be used whenever creating or editing documentation files in the src/routes/(docs)/docs/content/ directory or updating docs.json navigation.
|
||||
---
|
||||
|
||||
# Documentation Writer
|
||||
|
||||
Use this skill for all docs edits in `src/routes/(docs)/docs/content/` and when updating docs navigation in `src/routes/(docs)/docs.json`.
|
||||
|
||||
## Non-negotiable rules
|
||||
|
||||
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.
|
||||
|
||||
## Docs config model (current)
|
||||
|
||||
`docs.json` is versioned. Sidebar lives inside tabs:
|
||||
|
||||
- `versions[].content.navigation.tabs[].sidebar`
|
||||
- Sidebar groups contain `pages`
|
||||
- Page paths use `content` (legacy `slug` may still appear in older content)
|
||||
|
||||
When adding a new doc page, add it to the appropriate tab sidebar path.
|
||||
|
||||
## Versioned link policy (mandatory)
|
||||
|
||||
- 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.
|
||||
|
||||
## Required page format
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Page Title
|
||||
description: One-line summary of user outcome
|
||||
---
|
||||
```
|
||||
|
||||
- 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
|
||||
|
||||
## Preferred structure (default)
|
||||
|
||||
1. Short intro (1–2 sentences)
|
||||
2. Quick setup / minimum config
|
||||
3. Required variables/options table
|
||||
4. Verification step
|
||||
5. Top troubleshooting items
|
||||
|
||||
Only add extra sections if they materially improve task completion.
|
||||
|
||||
## Keep docs lean
|
||||
|
||||
Remove or avoid:
|
||||
|
||||
- Multiple near-identical examples
|
||||
- Long conceptual explainers
|
||||
- Platform-by-platform repetition unless behavior differs
|
||||
- Large checklists that restate earlier content
|
||||
|
||||
## Editing workflow
|
||||
|
||||
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.
|
||||
|
||||
## Review checklist
|
||||
|
||||
- [ ] 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,66 @@
|
||||
---
|
||||
name: svelte-code-writer
|
||||
description: CLI tools for Svelte 5 documentation lookup and code analysis. MUST be used whenever creating or editing any Svelte component (.svelte) or Svelte module (.svelte.ts/.svelte.js). If possible, this skill should be executed within the svelte-file-editor agent for optimal results.
|
||||
---
|
||||
|
||||
# Svelte 5 Code Writer
|
||||
|
||||
## CLI Tools
|
||||
|
||||
You have access to `@sveltejs/mcp` CLI for Svelte-specific assistance. Use these commands via `npx`:
|
||||
|
||||
### List Documentation Sections
|
||||
|
||||
```bash
|
||||
npx @sveltejs/mcp list-sections
|
||||
```
|
||||
|
||||
Lists all available Svelte 5 and SvelteKit documentation sections with titles and paths.
|
||||
|
||||
### Get Documentation
|
||||
|
||||
```bash
|
||||
npx @sveltejs/mcp get-documentation "<section1>,<section2>,..."
|
||||
```
|
||||
|
||||
Retrieves full documentation for specified sections. Use after `list-sections` to fetch relevant docs.
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
npx @sveltejs/mcp get-documentation "$state,$derived,$effect"
|
||||
```
|
||||
|
||||
### Svelte Autofixer
|
||||
|
||||
```bash
|
||||
npx @sveltejs/mcp svelte-autofixer "<code_or_path>" [options]
|
||||
```
|
||||
|
||||
Analyzes Svelte code and suggests fixes for common issues.
|
||||
|
||||
**Options:**
|
||||
|
||||
- `--async` - Enable async Svelte mode (default: false)
|
||||
- `--svelte-version` - Target version: 4 or 5 (default: 5)
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Analyze inline code (escape $ as \$)
|
||||
npx @sveltejs/mcp svelte-autofixer '<script>let count = \$state(0);</script>'
|
||||
|
||||
# Analyze a file
|
||||
npx @sveltejs/mcp svelte-autofixer ./src/lib/Component.svelte
|
||||
|
||||
# Target Svelte 4
|
||||
npx @sveltejs/mcp svelte-autofixer ./Component.svelte --svelte-version 4
|
||||
```
|
||||
|
||||
**Important:** When passing code with runes (`$state`, `$derived`, etc.) via the terminal, escape the `$` character as `\$` to prevent shell variable substitution.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Uncertain about syntax?** Run `list-sections` then `get-documentation` for relevant topics
|
||||
2. **Reviewing/debugging?** Run `svelte-autofixer` on the code to detect issues
|
||||
3. **Always validate** - Run `svelte-autofixer` before finalizing any Svelte component
|
||||
@@ -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
|
||||
@@ -1,26 +0,0 @@
|
||||
TZ=Etc/UTC
|
||||
KENER_SECRET_KEY=please_change_me_to_something_secure
|
||||
|
||||
# For SQLite database...
|
||||
DATABASE_URL=sqlite://./database/kener.sqlite.db
|
||||
|
||||
# For PostgreSQL database...
|
||||
# DATABASE_URL=postgresql://db_user:db_password@localhost:5432/kener_db
|
||||
# POSTGRES_PASSWORD=some_super_random_secure_password
|
||||
|
||||
# For MySQL database...
|
||||
# DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/kener_db
|
||||
# MYSQL_PASSWORD=some_super_random_secure_password
|
||||
|
||||
KENER_BASE_PATH=""
|
||||
ORIGIN=http://localhost:3000
|
||||
|
||||
RESEND_API_KEY=""
|
||||
RESEND_SENDER_EMAIL=Accounts <accounts@resend.dev>
|
||||
|
||||
# Likely no need to change...
|
||||
# NODE_ENV=production # already defined in container
|
||||
# PORT=3000 # default port Kener service is exposed upon
|
||||
|
||||
# Add the below variable if you would like to ‘white-label’ the product (aka. remove some of the attributions scattered throughout the app)
|
||||
# WHITE_LABEL=true
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: Kener v4 items
|
||||
about: Kener v4 items
|
||||
title: ''
|
||||
labels: kener_v4
|
||||
assignees: rajnandan1
|
||||
|
||||
---
|
||||
|
||||
Kener v4 items
|
||||
@@ -0,0 +1,428 @@
|
||||
# Kener API Development Instructions
|
||||
|
||||
This document provides guidelines for creating new API endpoints in Kener. Follow these patterns to maintain consistency across all APIs.
|
||||
|
||||
## API Architecture Overview
|
||||
|
||||
### Directory Structure
|
||||
```
|
||||
src/routes/(api)/api/
|
||||
├── {resource}/
|
||||
│ ├── +server.ts # GET (list), POST (create)
|
||||
│ └── [{resource}_id]/
|
||||
│ ├── +server.ts # GET, PATCH, DELETE (single resource)
|
||||
│ └── {sub-resource}/
|
||||
│ ├── +server.ts # GET (list), POST (create)
|
||||
│ └── [{sub_id}]/
|
||||
│ └── +server.ts # GET, PATCH, DELETE (single sub-resource)
|
||||
```
|
||||
|
||||
### Key Files
|
||||
- **Types**: `src/lib/types/api.ts` - All API request/response types (snake_case)
|
||||
- **Middleware**: `src/hooks.server.ts` - Authentication and resource validation
|
||||
- **App Locals**: `src/app.d.ts` - TypeScript declarations for `event.locals`
|
||||
- **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
|
||||
interface CreateMonitorRequest {
|
||||
monitor_tag: string;
|
||||
start_date_time: number;
|
||||
duration_seconds: number;
|
||||
}
|
||||
|
||||
// Wrong
|
||||
interface CreateMonitorRequest {
|
||||
monitorTag: string;
|
||||
startDateTime: number;
|
||||
durationSeconds: number;
|
||||
}
|
||||
```
|
||||
|
||||
### Type Naming Pattern
|
||||
```typescript
|
||||
// List response
|
||||
interface Get{Resource}sListResponse {
|
||||
{resources}: {Resource}Response[];
|
||||
}
|
||||
|
||||
// Single resource response
|
||||
interface Get{Resource}Response {
|
||||
{resource}: {Resource}DetailResponse;
|
||||
}
|
||||
|
||||
// Create request/response
|
||||
interface Create{Resource}Request { ... }
|
||||
interface Create{Resource}Response {
|
||||
{resource}: {Resource}Response;
|
||||
}
|
||||
|
||||
// Update request/response
|
||||
interface Update{Resource}Request { ... }
|
||||
interface Update{Resource}Response {
|
||||
{resource}: {Resource}Response;
|
||||
}
|
||||
|
||||
// Delete response
|
||||
interface Delete{Resource}Response {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Error responses (reuse existing)
|
||||
interface BadRequestResponse { error: { code: string; message: string; } }
|
||||
interface NotFoundResponse { error: { code: string; message: string; } }
|
||||
interface UnauthorizedResponse { error: { code: string; message: string; } }
|
||||
```
|
||||
|
||||
## Middleware Pattern
|
||||
|
||||
### 1. Add Route Regex Pattern in `hooks.server.ts`
|
||||
```typescript
|
||||
const RESOURCE_ID_ROUTE_REGEX = /^\/api\/resources\/(\d+)/;
|
||||
|
||||
function extractResourceId(pathname: string): number | null {
|
||||
const match = pathname.match(RESOURCE_ID_ROUTE_REGEX);
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Add Validation Block in `handle()` Function
|
||||
```typescript
|
||||
// Validate resource_id exists for /api/resources/:resource_id/* routes
|
||||
const resourceId = extractResourceId(pathname);
|
||||
if (resourceId) {
|
||||
const resource = await db.getResourceById(resourceId);
|
||||
if (!resource) {
|
||||
const errorResponse: NotFoundResponse = {
|
||||
error: {
|
||||
code: "NOT_FOUND",
|
||||
message: `Resource with id '${resourceId}' not found`,
|
||||
},
|
||||
};
|
||||
return json(errorResponse, { status: 404 });
|
||||
}
|
||||
// Store resource in locals for use in endpoints
|
||||
event.locals.resource = resource;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Declare in `app.d.ts`
|
||||
```typescript
|
||||
interface Locals {
|
||||
// Set by hooks.server.ts for /api/resources/:resource_id/* routes
|
||||
resource?: import("$lib/server/types/db").ResourceRecord;
|
||||
}
|
||||
```
|
||||
|
||||
## Endpoint Implementation Pattern
|
||||
|
||||
### GET (List)
|
||||
```typescript
|
||||
import { json, type RequestHandler } from "@sveltejs/kit";
|
||||
import db from "$lib/server/db/db";
|
||||
import type { GetResourcesListResponse, ResourceResponse } from "$lib/types/api";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
if (date instanceof Date) return date.toISOString();
|
||||
const parsed = new Date(date.replace(" ", "T") + "Z");
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
// Parse query params for filtering
|
||||
const statusParam = url.searchParams.get("status");
|
||||
const pageParam = url.searchParams.get("page");
|
||||
const limitParam = url.searchParams.get("limit");
|
||||
|
||||
const page = pageParam ? Math.max(1, parseInt(pageParam, 10) || 1) : 1;
|
||||
const limit = limitParam ? Math.min(100, Math.max(1, parseInt(limitParam, 10) || 20)) : 20;
|
||||
|
||||
// Build filter
|
||||
const filter: { status?: string } = {};
|
||||
if (statusParam) filter.status = statusParam;
|
||||
|
||||
// Query database
|
||||
const rawResources = await db.getResourcesPaginated(page, limit, filter);
|
||||
|
||||
// Transform to response format
|
||||
const resources: ResourceResponse[] = rawResources.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
created_at: formatDateToISO(r.created_at),
|
||||
updated_at: formatDateToISO(r.updated_at),
|
||||
}));
|
||||
|
||||
const response: GetResourcesListResponse = { resources };
|
||||
return json(response);
|
||||
};
|
||||
```
|
||||
|
||||
### POST (Create)
|
||||
```typescript
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
let body: CreateResourceRequest;
|
||||
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: { code: "BAD_REQUEST", message: "Invalid JSON body" },
|
||||
};
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!body.name || typeof body.name !== "string" || body.name.trim().length === 0) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: { code: "BAD_REQUEST", message: "name is required and must be a non-empty string" },
|
||||
};
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
|
||||
// Normalize timestamps using helper
|
||||
const normalizedTimestamp = GetMinuteStartTimestampUTC(body.start_date_time);
|
||||
|
||||
// Create resource
|
||||
const created = await db.createResource({
|
||||
name: body.name.trim(),
|
||||
start_date_time: normalizedTimestamp,
|
||||
});
|
||||
|
||||
// Build response
|
||||
const resourceResponse = await buildResourceResponse(created.id);
|
||||
const response: CreateResourceResponse = { resource: resourceResponse };
|
||||
return json(response, { status: 201 });
|
||||
};
|
||||
```
|
||||
|
||||
### GET (Single) - Uses Middleware
|
||||
```typescript
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
// Resource is validated by middleware and available in locals
|
||||
const resource = locals.resource!;
|
||||
|
||||
const resourceResponse = await buildResourceResponse(resource.id);
|
||||
const response: GetResourceResponse = { resource: resourceResponse };
|
||||
return json(response);
|
||||
};
|
||||
```
|
||||
|
||||
### PATCH (Update) - Uses Middleware
|
||||
```typescript
|
||||
export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
const existingResource = locals.resource!;
|
||||
|
||||
let body: UpdateResourceRequest;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json({ error: { code: "BAD_REQUEST", message: "Invalid JSON body" } }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate fields if provided
|
||||
if (body.status !== undefined && !["ACTIVE", "INACTIVE"].includes(body.status)) {
|
||||
return json({ error: { code: "BAD_REQUEST", message: "status must be 'ACTIVE' or 'INACTIVE'" } }, { status: 400 });
|
||||
}
|
||||
|
||||
// Build update data - only include fields present in request
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (body.name !== undefined) updateData.name = body.name.trim();
|
||||
if (body.status !== undefined) updateData.status = body.status;
|
||||
|
||||
// Update if there's data to update
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await db.updateResource(existingResource.id, updateData);
|
||||
}
|
||||
|
||||
const resourceResponse = await buildResourceResponse(existingResource.id);
|
||||
const response: UpdateResourceResponse = { resource: resourceResponse };
|
||||
return json(response);
|
||||
};
|
||||
```
|
||||
|
||||
### DELETE - Uses Middleware
|
||||
```typescript
|
||||
export const DELETE: RequestHandler = async ({ locals }) => {
|
||||
const resource = locals.resource!;
|
||||
|
||||
// Delete related records first (cascade)
|
||||
await db.deleteResourceRelatedRecords(resource.id);
|
||||
|
||||
// Delete the resource itself
|
||||
await db.deleteResource(resource.id);
|
||||
|
||||
const response: DeleteResourceResponse = {
|
||||
message: `Resource with id '${resource.id}' deleted successfully`,
|
||||
};
|
||||
return json(response);
|
||||
};
|
||||
```
|
||||
|
||||
## Timestamp Handling
|
||||
|
||||
### Always normalize timestamps
|
||||
```typescript
|
||||
import { GetMinuteStartTimestampUTC, GetNowTimestampUTC } from "$lib/server/tool";
|
||||
|
||||
// For user-provided timestamps - normalize to minute start
|
||||
const normalizedTs = GetMinuteStartTimestampUTC(body.start_date_time);
|
||||
|
||||
// For current time (when timestamp is optional)
|
||||
const now = GetNowTimestampUTC();
|
||||
|
||||
// For optional timestamp with fallback
|
||||
const timestamp = body.timestamp !== undefined
|
||||
? GetMinuteStartTimestampUTC(body.timestamp)
|
||||
: GetMinuteStartNowTimestampUTC();
|
||||
```
|
||||
|
||||
## Validation Patterns
|
||||
|
||||
### Required Field Validation
|
||||
```typescript
|
||||
if (body.field === undefined || body.field === null) {
|
||||
return json({ error: { code: "BAD_REQUEST", message: "field is required" } }, { status: 400 });
|
||||
}
|
||||
```
|
||||
|
||||
### Type Validation
|
||||
```typescript
|
||||
if (typeof body.count !== "number" || isNaN(body.count) || body.count <= 0) {
|
||||
return json({ error: { code: "BAD_REQUEST", message: "count must be a positive number" } }, { status: 400 });
|
||||
}
|
||||
```
|
||||
|
||||
### Enum Validation
|
||||
```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(", ")}` }
|
||||
}, { status: 400 });
|
||||
}
|
||||
```
|
||||
|
||||
### Foreign Key Validation
|
||||
```typescript
|
||||
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` }
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Array Validation
|
||||
```typescript
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Adding Repository Methods
|
||||
|
||||
### 1. Add Method to Repository Class
|
||||
```typescript
|
||||
// In src/lib/server/db/repositories/{resource}.ts
|
||||
async getResourcesWithDetails(options: {
|
||||
page: number;
|
||||
limit: number;
|
||||
filter?: { status?: string };
|
||||
}): Promise<{ resources: ResourceRecord[]; total: number }> {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Declare Method Type in DbImpl
|
||||
```typescript
|
||||
// In src/lib/server/db/dbimpl.ts - declarations section
|
||||
getResourcesWithDetails!: ResourceRepository["getResourcesWithDetails"];
|
||||
```
|
||||
|
||||
### 3. Bind Method in DbImpl Constructor
|
||||
```typescript
|
||||
// In src/lib/server/db/dbimpl.ts - bindResourceMethods()
|
||||
this.getResourcesWithDetails = this.resources.getResourcesWithDetails.bind(this.resources);
|
||||
```
|
||||
|
||||
## Common Imports
|
||||
```typescript
|
||||
import { json, type RequestHandler } from "@sveltejs/kit";
|
||||
import db from "$lib/server/db/db";
|
||||
import type {
|
||||
Get{Resource}Response,
|
||||
Create{Resource}Request,
|
||||
Create{Resource}Response,
|
||||
Update{Resource}Request,
|
||||
Update{Resource}Response,
|
||||
Delete{Resource}Response,
|
||||
BadRequestResponse,
|
||||
NotFoundResponse,
|
||||
} from "$lib/types/api";
|
||||
import { GetMinuteStartTimestampUTC, GetNowTimestampUTC } from "$lib/server/tool";
|
||||
```
|
||||
|
||||
## Response Status Codes
|
||||
- `200` - GET success, PATCH success, DELETE success
|
||||
- `201` - POST success (resource created)
|
||||
- `400` - Bad Request (validation errors)
|
||||
- `401` - Unauthorized (no/invalid token)
|
||||
- `404` - Not Found (resource doesn't exist)
|
||||
- `500` - Internal Server Error
|
||||
|
||||
## Testing with cURL
|
||||
```bash
|
||||
# List
|
||||
curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/resources
|
||||
|
||||
# Create
|
||||
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"name":"Test","start_date_time":1735689600}' \
|
||||
http://localhost:3000/api/resources
|
||||
|
||||
# Get single
|
||||
curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/resources/1
|
||||
|
||||
# Update
|
||||
curl -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"name":"Updated"}' \
|
||||
http://localhost:3000/api/resources/1
|
||||
|
||||
# Delete
|
||||
curl -X DELETE -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/resources/1
|
||||
```
|
||||
|
||||
## Checklist for New API
|
||||
|
||||
1. [ ] Define types in `src/lib/types/api.ts`
|
||||
2. [ ] Add middleware validation in `src/hooks.server.ts` (if resource has ID routes)
|
||||
3. [ ] Update `src/app.d.ts` with locals type
|
||||
4. [ ] Create endpoint files in `src/routes/(api)/api/{resource}/`
|
||||
5. [ ] Add repository methods if needed
|
||||
6. [ ] Bind repository methods in DbImpl
|
||||
7. [ ] Test all endpoints with cURL
|
||||
@@ -0,0 +1,144 @@
|
||||
# Kener - AI Coding Instructions
|
||||
|
||||
## Project Overview
|
||||
|
||||
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
|
||||
|
||||
### 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/`** - 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**
|
||||
- Connection string format: `sqlite://./path` or `postgresql://...` or `mysql://...`
|
||||
- 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 (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
|
||||
- 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.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/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()`:
|
||||
```typescript
|
||||
import { VerifyAPIKey } from "$lib/server/controllers/apiController";
|
||||
```
|
||||
|
||||
### Database Queries
|
||||
Always use the db singleton, never instantiate Knex directly:
|
||||
```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.ts`:
|
||||
```typescript
|
||||
import { GetMinuteStartTimestampUTC, GetNowTimestampUTC } from "$lib/server/tool";
|
||||
```
|
||||
|
||||
### i18n
|
||||
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/` (40+ components). Import pattern:
|
||||
```typescript
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
```
|
||||
|
||||
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:
|
||||
- `KENER_SECRET_KEY` - Secret key for auth
|
||||
- `ORIGIN` - Site URL (e.g., `http://localhost:3000`)
|
||||
- `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/`)
|
||||
- Client utilities: `src/lib/client/`
|
||||
- Route data loading: `+page.server.ts` / `+layout.server.ts`
|
||||
- API endpoints: `+server.ts` files returning `json()`
|
||||
|
||||
## Types & Interfaces
|
||||
|
||||
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 (`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}
|
||||
+7
-1
@@ -1,4 +1,6 @@
|
||||
.DS_Store
|
||||
.DS_STORE
|
||||
**/.DS_Store
|
||||
node_modules
|
||||
static/kener
|
||||
build
|
||||
@@ -28,4 +30,8 @@ uploads/*
|
||||
static/uploads/*
|
||||
!static/uploads/upload.dir
|
||||
temp.txt
|
||||
temp.js
|
||||
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
|
||||
@@ -0,0 +1,40 @@
|
||||
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
|
||||
|
||||
## Available MCP Tools:
|
||||
|
||||
### 1. list-sections
|
||||
|
||||
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
|
||||
When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections.
|
||||
|
||||
### 2. get-documentation
|
||||
|
||||
Retrieves full documentation content for specific sections. Accepts single or multiple sections.
|
||||
After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task.
|
||||
|
||||
### 3. svelte-autofixer
|
||||
|
||||
Analyzes Svelte code and returns issues and suggestions.
|
||||
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
|
||||
|
||||
### 4. playground-link
|
||||
|
||||
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
|
||||
+158
-112
@@ -1,148 +1,194 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Global build arguments (defined default values in case `.env.build` isn't loaded)
|
||||
ARG ALPINE_VERSION_TAG=23.7.0-alpine3.21
|
||||
ARG DEBIAN_VERSION_TAG=23.7.0-bookworm-slim
|
||||
ARG VARIANT=debian
|
||||
# =============================================================================
|
||||
# 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
|
||||
# =============================================================================
|
||||
|
||||
#==========================================================#
|
||||
# STAGE 1: BUILD STAGE #
|
||||
#==========================================================#
|
||||
ARG NODE_VERSION=24
|
||||
ARG VARIANT=alpine
|
||||
ARG WITH_DOCS=false
|
||||
ARG KENER_BASE_PATH=
|
||||
|
||||
FROM node:${DEBIAN_VERSION_TAG} AS builder-debian
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential=12.9 \
|
||||
python3=3.11.2-1+b1 \
|
||||
sqlite3=3.40.1-2+deb12u1 \
|
||||
libsqlite3-dev=3.40.1-2+deb12u1 \
|
||||
make=4.3-4.1 \
|
||||
node-gyp=9.3.0-2 \
|
||||
g++=4:12.2.0-3 \
|
||||
tzdata \
|
||||
iputils-ping=3:20221126-1+deb12u1 && \
|
||||
# =============================================================================
|
||||
# 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/*
|
||||
|
||||
FROM node:${ALPINE_VERSION_TAG} AS builder-alpine
|
||||
RUN apk add --no-cache --update \
|
||||
build-base=0.5-r3 \
|
||||
python3 \
|
||||
py3-pip \
|
||||
make=4.4.1-r2 \
|
||||
g++=14.2.0-r4 \
|
||||
sqlite \
|
||||
sqlite-dev \
|
||||
tzdata \
|
||||
iputils
|
||||
|
||||
# ---------- Selected variant ----------
|
||||
FROM builder-${VARIANT} AS builder
|
||||
|
||||
# Set environment variables
|
||||
ENV NPM_CONFIG_LOGLEVEL=error \
|
||||
VITE_BUILD_ENV=production
|
||||
ENV NPM_CONFIG_LOGLEVEL=error
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files for dependency installation
|
||||
ARG KENER_BASE_PATH
|
||||
ENV KENER_BASE_PATH=${KENER_BASE_PATH}
|
||||
|
||||
# 1. Copy package manifests first (maximises layer cache hits)
|
||||
COPY package*.json ./
|
||||
|
||||
# Install all dependencies, including `devDependencies` (cache enabled for faster builds)
|
||||
# TODO: Possibly add `--no-audit` flag to `npm ci` to prevent `npm` from running a security audit on installed packages. (By default, `npm install` performs an audit to check for vulnerabilities in dependencies, which can slow down installation. Adding this flag would skip the audit, thus making `npm install` significantly faster for the CI/CD pipeline.)
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --no-fund && \
|
||||
# 2. Install ALL dependencies (devDependencies needed for the build step)
|
||||
RUN npm ci --no-fund && \
|
||||
npm cache clean --force
|
||||
|
||||
# Copy application source code
|
||||
# 3. Copy the rest of the source tree
|
||||
COPY . .
|
||||
|
||||
# TODO: Reevaluate permissions (possibly reduce?)...
|
||||
# Remove docs directory and ensure required directories exist
|
||||
RUN rm -rf src/routes/\(docs\) \
|
||||
static/documentation \
|
||||
static/fonts/lato/full && \
|
||||
mkdir -p uploads database && \
|
||||
chmod -R 750 uploads database
|
||||
# 4. Create directories that the app expects
|
||||
RUN mkdir -p database
|
||||
|
||||
# Build the application and remove `devDependencies`
|
||||
RUN npm run build && \
|
||||
npm prune --omit=dev
|
||||
# 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
|
||||
|
||||
#==========================================================#
|
||||
# STAGE 2: PRODUCTION/FINAL STAGE #
|
||||
#==========================================================#
|
||||
# 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
|
||||
|
||||
FROM node:${DEBIAN_VERSION_TAG} AS final-debian
|
||||
# TODO: Consider adding `--no-install-recommends`, but will need testing (may further help reduce final build size)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
iputils-ping=3:20221126-1+deb12u1 \
|
||||
sqlite3=3.40.1-2+deb12u1 \
|
||||
tzdata \
|
||||
# TODO: Is it ok to change to `curl` here so that we don't have to maintain `wget` version mismatch between Debian architectures? (`curl` is only used for the container healthcheck and because there is an Alpine variant (best!) we probably don't care if the Debian image ends up building bigger due to `curl`.)
|
||||
curl && \
|
||||
# 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/*
|
||||
|
||||
FROM node:${ALPINE_VERSION_TAG} AS final-alpine
|
||||
RUN apk add --no-cache --update \
|
||||
iputils \
|
||||
sqlite \
|
||||
tzdata
|
||||
|
||||
# ---------- Selected variant ----------
|
||||
FROM final-${VARIANT} AS final
|
||||
|
||||
ARG PORT=3000 \
|
||||
USERNAME=node
|
||||
ARG PORT=3000
|
||||
ARG KENER_BASE_PATH=
|
||||
|
||||
# Set environment variables
|
||||
ENV HEALTHCHECK_PORT=$PORT \
|
||||
HEALTHCHECK_PATH= \
|
||||
NODE_ENV=production \
|
||||
NPM_CONFIG_LOGLEVEL=error \
|
||||
PORT=$PORT \
|
||||
TZ=Etc/UTC
|
||||
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"
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files build artifacts, and necessary files from builder stage
|
||||
COPY --chown=node:node --from=builder /app/src/lib/ ./src/lib/
|
||||
COPY --chown=node:node --from=builder /app/build ./build
|
||||
COPY --chown=node:node --from=builder /app/uploads ./uploads
|
||||
COPY --chown=node:node --from=builder /app/database ./database
|
||||
# TODO: Consider changing from copying `node_modules` to instead letting `npm ci --omit=dev` handle production dependencies. Right now, copying `node_modules` is leading to a smaller image, whereas letting `npm ci` handle the install in final image is slightly faster, but leads to larger image size. IMO, having a slightly longer build time (e.g. ~10 sec.) is better in the end to have a smaller image.
|
||||
# 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
|
||||
COPY --chown=node:node --from=builder /app/migrations ./migrations
|
||||
COPY --chown=node:node --from=builder /app/seeds ./seeds
|
||||
COPY --chown=node:node --from=builder /app/static ./static
|
||||
COPY --chown=node:node --from=builder /app/entrypoint.sh ./entrypoint.sh
|
||||
COPY --chown=node:node --from=builder /app/knexfile.js ./knexfile.js
|
||||
COPY --chown=node:node --from=builder /app/main.js ./main.js
|
||||
COPY --chown=node:node --from=builder /app/openapi.json ./openapi.json
|
||||
COPY --chown=node:node --from=builder /app/openapi.yaml ./openapi.yaml
|
||||
|
||||
# Package manifest (needed for ESM "type":"module" resolution)
|
||||
COPY --chown=node:node --from=builder /app/package.json ./package.json
|
||||
|
||||
# Ensure necessary directories are writable
|
||||
VOLUME ["/uploads", "/database"]
|
||||
# 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
|
||||
|
||||
# Set container timezone and make entrypoint script executable
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone && \
|
||||
chmod +x ./entrypoint.sh
|
||||
# TODO: To improve security, consider dropping unnecessary capabilities instead of granting image all network capabilities of host. (Maybe `setcap cap_net_raw+p /usr/bin/ping`, etc.) Could also drop all and then grant only the capabilities that are explicitly needed. Some examples are commented out below...
|
||||
# setcap cap_net_bind_service=+ep /usr/local/bin/node
|
||||
# setcap cap_net_bind_service=+ep /usr/bin/ping
|
||||
# setcap cap_net_bind_service=+ep /usr/bin/ping6
|
||||
# setcap cap_net_bind_service=+ep /usr/bin/tracepath
|
||||
# setcap cap_net_bind_service=+ep /usr/bin/clockdiff
|
||||
# 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/allPerms.ts ./src/lib/allPerms.ts
|
||||
COPY --chown=node:node --from=builder /app/src/lib/server/templates/general ./src/lib/server/templates/general
|
||||
|
||||
# Expose the application port
|
||||
EXPOSE $PORT
|
||||
# Locale JSON files (read at runtime by server-side i18n)
|
||||
COPY --chown=node:node --from=builder /app/src/lib/locales ./src/lib/locales
|
||||
|
||||
# Add a healthcheck to the container; `wget` vs. `curl` depending on base image. Using this approach because `wget` does not actually maintain versioning across architectures, so we cannot pin a `wget` version (in above `final-debian` base, `apt-get install`) between differing architectures (e.g. arm64, amd64)
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD sh -c 'if [ -f "/etc/alpine-release" ]; then wget --quiet --spider http://localhost:$HEALTHCHECK_PORT$HEALTHCHECK_PATH || exit 1; else curl --silent --head --fail http://localhost:$HEALTHCHECK_PORT$HEALTHCHECK_PATH || exit 1; fi'
|
||||
# Build output (SvelteKit client/server + esbuild main.js) — changes most often
|
||||
COPY --chown=node:node --from=builder /app/build ./build
|
||||
|
||||
# TODO: Revisit letting user define $PUID & $PGID overrides (e.g. `addgroup -g $PGID newgroup && adduser -D -G newgroup -u $PUID node`) as well as potentially ensure no root user exists. (Make sure no processes are running as root, first!)
|
||||
# Use a non-root user (recommended for security)
|
||||
USER $USERNAME
|
||||
# Docs runtime files (index-docs script + markdown sources; empty when WITH_DOCS=false)
|
||||
COPY --chown=node:node --from=builder /docs-runtime/ ./
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
CMD ["node", "main"]
|
||||
# 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"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Raj Nandan Sharma
|
||||
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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Kener - Stunning Status Pages
|
||||
|
||||
<p align="center">
|
||||
<img src="https://kener.ing/newbg.png?v=1" width="100%" height="auto" class="rounded-lg shadow-lg" alt="kener example illustration">
|
||||
<img src="https://kener.ing/og.jpg?v=1" width="100%" height="auto" class="rounded-lg shadow-lg" alt="kener example illustration">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -20,6 +20,7 @@
|
||||
<a href="https://github.com/rajnandan1/kener/actions/workflows/publish-images.yml"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/rajnandan1/kener/publish-images.yml" /></a>
|
||||
<a href="https://github.com/rajnandan1/kener/commit/HEAD"><img src="https://img.shields.io/github/last-commit/rajnandan1/kener/main" alt="" /></a>
|
||||
<a href="https://github.com/rajnandan1/kener/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/rajnandan1/kener.svg" /></a>
|
||||
<a href="https://deepwiki.com/rajnandan1/kener"><img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -43,8 +44,16 @@
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
| [🌍 Live Server](https://kener.ing) | [🎉 Quick Start](https://kener.ing/docs/quick-start) | [🗄 Documentation](https://kener.ing/docs/home) |
|
||||
| ----------------------------------- | ---------------------------------------------------- | ----------------------------------------------- |
|
||||
| [🌍 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?
|
||||
|
||||
@@ -62,151 +71,154 @@ Designed with **ease of use** and **customization in mind**, Kener provides all
|
||||
|
||||
“Kener” is inspired by the Assamese word _“Kene”_, meaning _“how’s it going?”_. The _‘.ing’_ was added because, well… that domain was available. 😄
|
||||
|
||||
## Installation
|
||||
## Quick Start
|
||||
|
||||
### Manual
|
||||
Get Kener running in minutes.
|
||||
|
||||
```shell
|
||||
# Clone the repository
|
||||
### 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
|
||||
```
|
||||
|
||||
Open `http://localhost:3000`.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Set a strong `KENER_SECRET_KEY` and set `ORIGIN` to your public URL before starting for the first time.
|
||||
|
||||
Use `docker-compose.dev.yml` when you want to build from local source instead of pulling the published image:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
```
|
||||
|
||||
Or combine both files to keep base production config while overriding Kener with a local build:
|
||||
|
||||
```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
|
||||
cp .env.example .env
|
||||
npm run dev
|
||||
|
||||
# Start Redis (example)
|
||||
docker run -d --name kener-redis -p 6379:6379 redis:7-alpine
|
||||
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
### Docker
|
||||
Create a `.env` with at least:
|
||||
|
||||
Official Docker images for **Kener** are available on [Docker Hub](https://hub.docker.com/r/rajnandan1/kener). Multiple versions are maintained to support different use cases.
|
||||
|
||||
<a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.2.16"><img src="https://img.shields.io/badge/Latest_Stable_Release-3.2.16-blue" alt="Kener latest stable version: 3.2.16" /></a>
|
||||
|
||||
#### Available Tags
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Image Tag</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" colspan="2" style="color:#A81D33;text-align:left;">Debian 12 <small>(Bookwork Slim)</small> w/ Node.js v23.7.0 <strong><em>(default)</em></strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=latest" target="_blank"><code>latest</code></td>
|
||||
<td>Latest stable release (aka 3.2.16)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.2.16" target="_blank"><code>3.2.16</code></a></td>
|
||||
<td>Specific release version</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.2" target="_blank"><code>3.2</code></a></td>
|
||||
<td>Major-minor version tag pointing to the latest patch (3.2.16) release within that minor version (3.2.x)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3" target="_blank"><code>3</code></a></td>
|
||||
<td>Major version tag pointing to the latest stable (3.2.16) release within that major version (3.x.x)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" colspan="2" style="color:#0D597F;text-align:left;">Alpine Linux 3.21 w/ Node.js v23.7.0 <strong><em>(smallest image size)</em></strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=alpine" target="_blank"><code>alpine</code></td>
|
||||
<td>Latest stable release (aka 3.2.16)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.2.16-alpine" target="_blank"><code>3.2.16-alpine</code></a></td>
|
||||
<td>Specific release version</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.2-alpine" target="_blank"><code>3.2-alpine</code></a></td>
|
||||
<td>Major-minor version tag pointing to the latest patch (3.2.16) release within that minor version (3.2.x)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3-alpine" target="_blank"><code>3-alpine</code></a></td>
|
||||
<td>Major version tag pointing to the latest stable (3.2.16) release within that major version (3.x.x)</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
#### Usage
|
||||
|
||||
Pull the latest stable version:
|
||||
|
||||
```sh
|
||||
docker pull rajnandan1/kener:latest
|
||||
```dotenv
|
||||
KENER_SECRET_KEY=replace_with_a_random_string
|
||||
ORIGIN=http://localhost:3000
|
||||
REDIS_URL=redis://localhost:6379
|
||||
PORT=3000
|
||||
```
|
||||
|
||||
Or use the smaller, Alpine-based variant:
|
||||
For the full quick start (including local Docker builds and dev mode), see the docs:
|
||||
|
||||
```sh
|
||||
docker pull rajnandan1/kener:alpine
|
||||
```
|
||||
|
||||
For a production setup, refer to the sample [docker-compose.yml](https://github.com/rajnandan1/kener/blob/main/docker-compose.yml).
|
||||
This keeps things clean, structured, and easy to read while preserving all the details.
|
||||
|
||||
### One Click
|
||||
|
||||
[](https://railway.com/template/spSvic?referralCode=1Pn7vs)
|
||||
- https://kener.ing/docs/v4/getting-started/quick-start
|
||||
|
||||
## Features
|
||||
|
||||
Here are some of the features that you get out of the box. Please read the documentation to know how to use them.
|
||||
Kener combines public status page essentials with advanced admin workflows.
|
||||
|
||||
### 📊 Monitoring and Tracking
|
||||
### 📊 Monitoring, Reliability, and Communication
|
||||
|
||||
- Advanced **application performance monitoring** tools
|
||||
- **Real-time network monitoring** capabilities
|
||||
- Supports **polling HTTP endpoints** or **pushing data** via REST APIs
|
||||
- **Timezone auto-adjustment** for visitors
|
||||
- Organize monitors into **custom sections**
|
||||
- **Cron-based scheduling** (minimum: **every minute**)
|
||||
- **Create complex API polls** (chaining, secrets, etc.)
|
||||
- Set a **default status** for monitors
|
||||
- Supports **base path hosting in Kubernetes (k8s)**
|
||||
- **Pre-built Docker images** for easy deployment
|
||||
- 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
|
||||
|
||||
### 🎨 Customization and Branding
|
||||
### 🎨 Status Page Experience and Branding
|
||||
|
||||
- Fully **customizable status page**
|
||||
- **Badge generation** for status and uptime tracking
|
||||
- Support for **custom domains**
|
||||
- Embed monitors as **iframes or widgets**
|
||||
- **Light & Dark Mode**
|
||||
- **Internationalization (i18n) support**
|
||||
- **Sleek, beautifully crafted UI**
|
||||
- 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
|
||||
|
||||
### 🚨 Incident Management
|
||||
### 🛠️ Operations, Collaboration, and Automation
|
||||
|
||||
- **Incident tracking & communication** tools
|
||||
- **Comprehensive APIs** for incident management
|
||||
|
||||
### 🧑💻 User Experience and Design
|
||||
|
||||
- **Accessible & user-friendly interface**
|
||||
- **Quick & easy installation**
|
||||
- **Responsive design** for all devices
|
||||
- **Auto SEO & Social Media ready**
|
||||
- **Server-Side Rendering (SSR) for better performance**
|
||||
|
||||
<div align="left">
|
||||
<img alt="Visitor Stats" src="https://widgetbite.com/stats/rajnandan"/>
|
||||
</div>
|
||||
- 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/)
|
||||
- [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)
|
||||
- [Sponsor Me Using GitHub](https://github.com/sponsors/rajnandan1)
|
||||
|
||||
☕ [Buy Me a Coffee](https://www.buymeacoffee.com/rajnandan1)
|
||||
|
||||

|
||||
- [Buy Me a Coffee](https://www.buymeacoffee.com/rajnandan1)
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
# Kener - Stunning Status Pages
|
||||
|
||||
<p align="center">
|
||||
<img src="https://kener.ing/newbg.png?v=1" width="100%" height="auto" class="rounded-lg shadow-lg" alt="kener example illustration">
|
||||
</p>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</p>
|
||||
|
||||
<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/quick-start) | [🗄 Documentation](https://kener.ing/docs/home) |
|
||||
| ----------------------------------- | ---------------------------------------------------- | ----------------------------------------------- |
|
||||
|
||||
## 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. 😄
|
||||
|
||||
## Installation
|
||||
|
||||
### Manual
|
||||
|
||||
```shell
|
||||
# Clone the repository
|
||||
git clone https://github.com/rajnandan1/kener.git
|
||||
cd kener
|
||||
npm install
|
||||
cp .env.example .env
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
Official Docker images for **Kener** are available on [Docker Hub](https://hub.docker.com/r/rajnandan1/kener). Multiple versions are maintained to support different use cases.
|
||||
|
||||
<a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name={{kener_full_version}}"><img src="https://img.shields.io/badge/Latest_Stable_Release-{{kener_full_version}}-blue" alt="Kener latest stable version: {{kener_full_version}}" /></a>
|
||||
|
||||
#### Available Tags
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Image Tag</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" colspan="2" style="color:#A81D33;text-align:left;">Debian 12 <small>(Bookwork Slim)</small> w/ Node.js v23.7.0 <strong><em>(default)</em></strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=latest" target="_blank"><code>latest</code></td>
|
||||
<td>Latest stable release (aka {{kener_full_version}})</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name={{kener_full_version}}" target="_blank"><code>{{kener_full_version}}</code></a></td>
|
||||
<td>Specific release version</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name={{kener_major_minor_version}}" target="_blank"><code>{{kener_major_minor_version}}</code></a></td>
|
||||
<td>Major-minor version tag pointing to the latest patch ({{kener_full_version}}) release within that minor version ({{kener_major_minor_version}}.x)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name={{kener_major_version}}" target="_blank"><code>{{kener_major_version}}</code></a></td>
|
||||
<td>Major version tag pointing to the latest stable ({{kener_full_version}}) release within that major version ({{kener_major_version}}.x.x)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" colspan="2" style="color:#0D597F;text-align:left;">Alpine Linux 3.21 w/ Node.js v23.7.0 <strong><em>(smallest image size)</em></strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=alpine" target="_blank"><code>alpine</code></td>
|
||||
<td>Latest stable release (aka {{kener_full_version}})</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name={{kener_full_version}}-alpine" target="_blank"><code>{{kener_full_version}}-alpine</code></a></td>
|
||||
<td>Specific release version</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name={{kener_major_minor_version}}-alpine" target="_blank"><code>{{kener_major_minor_version}}-alpine</code></a></td>
|
||||
<td>Major-minor version tag pointing to the latest patch ({{kener_full_version}}) release within that minor version ({{kener_major_minor_version}}.x)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name={{kener_major_version}}-alpine" target="_blank"><code>{{kener_major_version}}-alpine</code></a></td>
|
||||
<td>Major version tag pointing to the latest stable ({{kener_full_version}}) release within that major version ({{kener_major_version}}.x.x)</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
#### Usage
|
||||
|
||||
Pull the latest stable version:
|
||||
|
||||
```sh
|
||||
docker pull rajnandan1/kener:latest
|
||||
```
|
||||
|
||||
Or use the smaller, Alpine-based variant:
|
||||
|
||||
```sh
|
||||
docker pull rajnandan1/kener:alpine
|
||||
```
|
||||
|
||||
For a production setup, refer to the sample [docker-compose.yml](https://github.com/rajnandan1/kener/blob/main/docker-compose.yml).
|
||||
This keeps things clean, structured, and easy to read while preserving all the details.
|
||||
|
||||
### One Click
|
||||
|
||||
[](https://railway.com/template/spSvic?referralCode=1Pn7vs)
|
||||
|
||||
## Features
|
||||
|
||||
Here are some of the features that you get out of the box. Please read the documentation to know how to use them.
|
||||
|
||||
### 📊 Monitoring and Tracking
|
||||
|
||||
- Advanced **application performance monitoring** tools
|
||||
- **Real-time network monitoring** capabilities
|
||||
- Supports **polling HTTP endpoints** or **pushing data** via REST APIs
|
||||
- **Timezone auto-adjustment** for visitors
|
||||
- Organize monitors into **custom sections**
|
||||
- **Cron-based scheduling** (minimum: **every minute**)
|
||||
- **Create complex API polls** (chaining, secrets, etc.)
|
||||
- Set a **default status** for monitors
|
||||
- Supports **base path hosting in Kubernetes (k8s)**
|
||||
- **Pre-built Docker images** for easy deployment
|
||||
|
||||
### 🎨 Customization and Branding
|
||||
|
||||
- Fully **customizable status page**
|
||||
- **Badge generation** for status and uptime tracking
|
||||
- Support for **custom domains**
|
||||
- Embed monitors as **iframes or widgets**
|
||||
- **Light & Dark Mode**
|
||||
- **Internationalization (i18n) support**
|
||||
- **Sleek, beautifully crafted UI**
|
||||
|
||||
### 🚨 Incident Management
|
||||
|
||||
- **Incident tracking & communication** tools
|
||||
- **Comprehensive APIs** for incident management
|
||||
|
||||
### 🧑💻 User Experience and Design
|
||||
|
||||
- **Accessible & user-friendly interface**
|
||||
- **Quick & easy installation**
|
||||
- **Responsive design** for all devices
|
||||
- **Auto SEO & Social Media ready**
|
||||
- **Server-Side Rendering (SSR) for better performance**
|
||||
|
||||
<div align="left">
|
||||
<img alt="Visitor Stats" src="https://widgetbite.com/stats/rajnandan"/>
|
||||
</div>
|
||||
|
||||
## 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)
|
||||
+14
-11
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"style": "default",
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/app.postcss",
|
||||
"baseColor": "slate"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils"
|
||||
}
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"tailwind": {
|
||||
"css": "src/routes/layout.css",
|
||||
"baseColor": "zinc"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
+104
-50
@@ -1,61 +1,115 @@
|
||||
# Docker Compose Configuration
|
||||
# Description: This file sets up a multi-container environment for Kener (https://github.com/rajnandan1/kener).
|
||||
# Last Updated: 2025-02-08
|
||||
# Docker Compose Version: 3.8
|
||||
# Notes: Ensure that you specify a random value for the `KENER_SECRET_KEY` environment variable before running `docker-compose up -d`.
|
||||
|
||||
version: '3.8'
|
||||
# =============================================================================
|
||||
# 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:
|
||||
kener:
|
||||
image: rajnandan1/kener:latest # Change to 'rajnandan1/kener:alpine' for an even smaller image! 😁🚀
|
||||
container_name: kener
|
||||
# env_file: custom.env # Uncomment this if you are needing to export environment variables from a custom environment file. By default, Docker will import any variables that exist in `.env`
|
||||
environment:
|
||||
TZ: Etc/UTC
|
||||
KENER_SECRET_KEY: replace_me_with_a_random_string # Keep private!! - best to define in `.env` file or through Docker Secret
|
||||
# DATABASE_URL: custom_db_url # By default, a SQLite database is used - you may override the database url/type here
|
||||
# RESEND_API_KEY:
|
||||
# RESEND_SENDER_EMAIL:
|
||||
|
||||
### You most likely will NOT need to change anything below this line. Be sure you know what you're doing!! (https://kener.ing/docs/deployment/#docker-environment-variables)
|
||||
|
||||
# PORT: 3000 # Port that app listens on in the container
|
||||
# KENER_BASE_PATH: # By default, Kener runs at `/`. You may change this to be, e.g. `/status`, etc. Do NOT add a trailing slash!! (more info here: https://kener.ing/docs/deployment/#docker-environment-variables)
|
||||
# ORIGIN: http://localhost:3000
|
||||
# NODE_ENV: production # This is already set to "production" by default within the container
|
||||
ports:
|
||||
- '3000:3000/tcp'
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redis — required for BullMQ queues, caching, and scheduler
|
||||
# ---------------------------------------------------------------------------
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: kener-redis
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- data:/app/database # We suggest using a Docker named volume, which is more performant for databases
|
||||
- $(pwd)/uploads:/app/uploads
|
||||
# read_only: true # Uncommenting this fortifies security by marking the container's filesystem as read-only (aka no data can be written to the container's filesystem except for explicitly defined writable volumes and bind mounts, an exception has already been defined for `/database` and `/uploads`)
|
||||
restart: unless-stopped
|
||||
# depends_on: # <-- Uncomment if you would like to use PostgreSQL or MySQL
|
||||
# - postgres # ...instead of SQLite
|
||||
# - mysql #
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Only use below section if you would like to utilize PostgreSQL instead of Kener's default SQLite database. (Don't forget to set `DATABASE_URL` in `kener` service to be: `DATABASE_URL=postgresql://db_user:db_password@localhost:5432/kener_db`)
|
||||
postgres:
|
||||
image: postgres:alpine
|
||||
container_name: postgres
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kener — Status Page Application
|
||||
# ---------------------------------------------------------------------------
|
||||
kener:
|
||||
image: rajnandan1/kener:latest
|
||||
# For Alpine variant use: rajnandan1/kener:alpine
|
||||
container_name: kener
|
||||
environment:
|
||||
POSTGRES_USER: user
|
||||
POSTGRES_PASSWORD: some_super_random_secure_password # Best to define this in `.env` or via Docker Secret!!
|
||||
POSTGRES_DB: kener_db
|
||||
# ── 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
|
||||
|
||||
# Only use below section if you would like to utilize MySQL instead of Kener's default SQLite database. (Don't forget to set `DATABASE_URL` in `kener` service to be: `DATABASE_URL=mysql://db_user:db_password@localhost:3306/kener_db`)
|
||||
mysql:
|
||||
image: mariadb:11
|
||||
container_name: mysql
|
||||
environment:
|
||||
MYSQL_USER: user
|
||||
MYSQL_PASSWORD: some_super_random_secure_password # Best to define this in `.env` or via Docker Secret!!
|
||||
MYSQL_DATABASE: kener_db
|
||||
MYSQL_RANDOM_ROOT_PASSWORD: true
|
||||
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,36 +0,0 @@
|
||||
#!/usr/bin/with-contenv bash
|
||||
|
||||
# used https://github.com/linuxserver/docker-plex as a template
|
||||
|
||||
POPULATE_EXAMPLES=false
|
||||
|
||||
echo "-------------------------------------"
|
||||
echo -e "Setting up app config directory based on CONFIG_DIR env: ${CONFIG_DIR}\n"
|
||||
|
||||
# make config folder if it does not exist
|
||||
if [ ! -d "${CONFIG_DIR}" ]; then
|
||||
echo "Directory does not exist! Creating..."
|
||||
POPULATE_EXAMPLES=true
|
||||
mkdir -p "${CONFIG_DIR}"
|
||||
else
|
||||
if [ "$(ls -A ${CONFIG_DIR})" ]; then
|
||||
echo "Directory is not empty, not populating with defaults."
|
||||
else
|
||||
POPULATE_EXAMPLES=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# add example configs
|
||||
if [ "$POPULATE_EXAMPLES" = true ]; then
|
||||
echo "Directory is empty, adding defaults..."
|
||||
mkdir -p "${CONFIG_DIR}"/static
|
||||
cp -r /app/static/. "${CONFIG_DIR}"/static
|
||||
cp /app/config/monitors.example.yaml "${CONFIG_DIR}"/monitors.yaml
|
||||
cp /app/config/site.example.yaml "${CONFIG_DIR}"/site.yaml
|
||||
fi
|
||||
|
||||
# permissions
|
||||
echo "chown'ing directory to ensure correct permissions."
|
||||
chown -R abc:abc "${CONFIG_DIR}"
|
||||
echo "Done!"
|
||||
echo -e "-------------------------------------\n"
|
||||
@@ -1 +0,0 @@
|
||||
oneshot
|
||||
@@ -1 +0,0 @@
|
||||
/etc/s6-overlay/s6-rc.d/init-app-config/run
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/usr/bin/with-contenv bash
|
||||
|
||||
echo -e "\nApp is starting!"
|
||||
export NODE_ENV=production
|
||||
cd /app || exit
|
||||
|
||||
# Run build first
|
||||
exec \
|
||||
s6-setuidgid abc /usr/bin/node $NODE_ARGS /app/main.js
|
||||
@@ -1 +0,0 @@
|
||||
longrun
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
title: View Alerts | Kener
|
||||
description: Learn how to view alerts in kener.
|
||||
---
|
||||
|
||||
# View Alerts
|
||||
|
||||
Alerts are used to notify you when a monitor goes down or up. You can view alerts in the Kener dashboard.
|
||||
|
||||
## ID
|
||||
|
||||
The unique ID of the alert.
|
||||
|
||||
## Type
|
||||
|
||||
The type of status that the alert is for. Example DOWN, DEGRADED
|
||||
|
||||
## Monitor
|
||||
|
||||
The monitor for which the alert is triggered.
|
||||
|
||||
## Status
|
||||
|
||||
The status of the alert. TRIGGERED, RESOLVED
|
||||
|
||||
## Checks
|
||||
|
||||
The number of checks that have been done.
|
||||
|
||||
## Incident
|
||||
|
||||
The incident that the alert is part of.
|
||||
|
||||
## Created At
|
||||
|
||||
The time when the alert was created.
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
title: Categorize Monitors Guide | Kener
|
||||
description: Categorize Monitors in Kener
|
||||
---
|
||||
|
||||
# Categorize Monitors
|
||||
|
||||
Let us add a category to our monitors.
|
||||
|
||||
## Sample monitors.yaml
|
||||
|
||||
```yaml
|
||||
- name: OkBookmarks
|
||||
description: A free bookmark manager that lets you save and search your bookmarks in the cloud.
|
||||
tag: "okbookmarks"
|
||||
image: "https://okbookmarks.com/assets/img/extension_icon128.png"
|
||||
api:
|
||||
method: GET
|
||||
url: https://okbookmarks.com
|
||||
- name: Earth
|
||||
description: Our blue planet
|
||||
tag: "earth"
|
||||
default_status: "UP"
|
||||
image: "/earth.png"
|
||||
category: "Hello"
|
||||
- name: Frogment
|
||||
description: A free openAPI spec editor and linter that breaks down your spec into fragments to make editing easier and more intuitive. Visit https://www.frogment.com
|
||||
tag: "frogment"
|
||||
image: "/frogment.png"
|
||||
api:
|
||||
method: GET
|
||||
url: https://www.frogment.com
|
||||
```
|
||||
|
||||
## Sample site.yaml
|
||||
|
||||
```yaml
|
||||
#...
|
||||
categories:
|
||||
- name: Hello
|
||||
description: Say Hello to the world
|
||||
#...
|
||||
```
|
||||
|
||||
The above will have OkBookmarks and Frogment under home. Earth will be under Hello category.
|
||||
@@ -1,62 +0,0 @@
|
||||
---
|
||||
title: Github Setup | Kener
|
||||
description: Kener uses github for incident management. Issues created in github using certain tags go to kener as incidents.
|
||||
---
|
||||
|
||||
# Github Setup
|
||||
|
||||
Kener uses github for incident management. Issues created in github using certain tags go to kener as incidents.
|
||||
|
||||
## Step 1: Create Github Repository
|
||||
|
||||
Create a [Github Repository](https://github.com/new). It can be either public or private.
|
||||
|
||||
## Step 2: Create Github Token
|
||||
|
||||
You can create either a classic token or personal access token
|
||||
|
||||
### Creating Classic Token
|
||||
|
||||
- Go to [Tokens](https://github.com/settings/tokens/new)
|
||||
- Note: kener
|
||||
- Expiration: No Expiration
|
||||
- Scopes: write:packages
|
||||
- Click on generate Token
|
||||
|
||||
### Creating Personal Access Token
|
||||
|
||||
- Go to [Personal Access Token](https://github.com/settings/personal-access-tokens/new)
|
||||
- Token Name: kener
|
||||
- Expiration: Use custom to select a calendar date
|
||||
- Description: My Kener
|
||||
- Repository access: Check Only Selected Repositories. Select your github repository
|
||||
- Repository Permission: Select Issues Read Write
|
||||
- Click on generate token
|
||||
|
||||
## Step 3: Set environment
|
||||
|
||||
```bash
|
||||
export GH_TOKEN=github_pat_11AD3ZA3Y0
|
||||
```
|
||||
|
||||
## Step 4: Add to Kener
|
||||
|
||||
Add your repository details to kener.
|
||||
|
||||

|
||||
|
||||
### Github API URL
|
||||
|
||||
If you are on github enterprise you can set the github api url. For most users, it will be `https://api.github.com`
|
||||
|
||||
### Github Repo
|
||||
|
||||
The repository name you created in step 1
|
||||
|
||||
### Github Username
|
||||
|
||||
Your github username
|
||||
|
||||
### Incident History
|
||||
|
||||
It is in hours. It means if an issue is created before X hours then kener would not honor it. What it means, is that kener would not show it under active incidents nor it will update the uptime. Default is 30\*24 hours = 720 hours.
|
||||
@@ -1,76 +0,0 @@
|
||||
---
|
||||
title: Kener Documentation | Open Source Status Page
|
||||
description: Kener is a feature-rich and modern status page system built with SvelteKit and NodeJS. It is open-source and free to use.
|
||||
---
|
||||
|
||||
# Kener - A Feature Rich & Modern Status Page
|
||||
|
||||
<p align="center">
|
||||
<img src="/newbg.png" width="100%" height="auto" class="rounded-lg shadow-lg" alt="kener example illustration">
|
||||
</p>
|
||||
|
||||
<p class="flex space-x-2 justify-center">
|
||||
<a href="https://github.com/rajnandan1/kener/stargazers" >
|
||||
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/rajnandan1/kener?label=Star%20Repo&
|
||||
style=social">
|
||||
</a>
|
||||
<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://hub.docker.com/r/rajnandan1/kener" >
|
||||
<img src="https://img.shields.io/docker/pulls/rajnandan1/kener" alt="Docker Kener" />
|
||||
</a>
|
||||
</p>
|
||||
<div class="flex gap-4 justify-center">
|
||||
<picture>
|
||||
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f38a/512.webp" type="image/webp">
|
||||
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f38a/512.gif" alt="🎊" width="32" height="32">
|
||||
</picture>
|
||||
<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/2049_fe0f/512.webp" type="image/webp">
|
||||
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/2049_fe0f/512.gif" alt="⁉" width="32" height="32">
|
||||
</picture>
|
||||
</div>
|
||||
<div class="flex gap-2 kener-home-links">
|
||||
<a href="https://kener.ing" class="flex-1 border rounded-md py-4 px-2 text-center">
|
||||
Live Demo
|
||||
</a>
|
||||
<a href="https://kener.ing/docs/quick-start" class="flex-1 border rounded-md py-4 px-2 text-center">
|
||||
Quick Start
|
||||
</a>
|
||||
<a href="https://github.com/rajnandan1/kener" class="flex-1 border rounded-md py-4 px-2 text-center">
|
||||
Clone
|
||||
</a>
|
||||
<a href="https://kener.ing/docs/deployment" class="flex-1 border rounded-md py-4 px-2 text-center">
|
||||
Deploy
|
||||
</a>
|
||||
<a href="https://kener.ing/docs/kener-apis" class="flex-1 border rounded-md py-4 px-2 text-center">
|
||||
APIs
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## Forget Boring Status Pages. Hello Kener!
|
||||
|
||||
**Kener** isn't just another status page – it's a beautifully crafted, lightning-fast monitoring system that makes your services look good even when they're down. Built with modern tech (**SvelteKit** + **NodeJS**), it delivers enterprise-grade reliability monitoring without the enterprise price tag (it's completely free!).
|
||||
|
||||
### Why Teams Are Switching to Kener
|
||||
|
||||
- **📈 Up and running in minutes**, not days – from Docker to deployment in less time than it takes to make coffee
|
||||
- **🎭 Dark mode that actually looks good** – because your 3 AM incident responses shouldn't burn your retinas
|
||||
- **🧩 Ridiculously customizable** – make it match your brand or go wild with a unique design
|
||||
- **⚡ Blazing fast performance** – server-rendered for speed that both users and search engines love
|
||||
- **🌐 Works everywhere** – auto-adjusts to visitors' time zones and looks great on any device
|
||||
- **🔄 Real-time data** with gorgeous visualizations that make your uptime metrics pop
|
||||
- **🛠️ Modern tooling** without the bloat – everything you need, nothing you don't
|
||||
|
||||
### Used by Companies That Value Simplicity and Power
|
||||
|
||||
From indie developers to growing startups, teams are choosing Kener when they need reliability monitoring that doesn't require a dedicated DevOps team to maintain.
|
||||
|
||||
"_My team set up Kener in less than an hour, and our customers love the transparency it provides. The embedded widgets on our pricing page increased conversions by showing our reliability upfront._" – A happy user
|
||||
|
||||
## Ready to Transform Your Status Page?
|
||||
@@ -1,22 +0,0 @@
|
||||
# Kener Showcase
|
||||
|
||||
This page is a showcase of how kener is getting used in the wild. If you want to add your site here, please raise a PR and modify this [file](https://github.com/rajnandan1/kener-docs/blob/main/docs/md/docs/showcase.md)
|
||||
|
||||
#### [Kener](https://kener.ing)
|
||||
#### [Cashfree Payments India](https://statuspage.cashfree.com/)
|
||||
#### [status.orhun.dev](https://status.orhun.dev/)
|
||||
#### [status.ordinalsbot.com](https://status.ordinalsbot.com/)
|
||||
#### [status.britsov.com](https://status.britsov.net/)
|
||||
#### [status.gosu.bar](https://status.gosu.bar/)
|
||||
#### [stat.imsun.org](https://stat.imsun.org/)
|
||||
#### [Goomer](https://status.goomer.com.br/)
|
||||
#### [kennek.io](https://status.kennek.io/)
|
||||
#### [evelan.io](https://status.evelan.io/)
|
||||
#### [evelan.io](https://status.evelan.io/)
|
||||
#### [sveir.xyz](https://status.sveir.xyz/)
|
||||
#### [cellcast.com](https://status.cellcast.com/)
|
||||
#### [flytbase.com](https://status.flytbase.com/)
|
||||
#### [scriptor-artis.fr](https://status.scriptor-artis.fr/)
|
||||
#### [jiance.f.ozizio.com](http://jiance.f.ozizio.com/)
|
||||
#### [sshaw.cn](https://s.sshaw.cn/)
|
||||
#### [donotes.app](https://status.donotes.app)
|
||||
@@ -1,197 +0,0 @@
|
||||
{
|
||||
"sidebar": [
|
||||
{
|
||||
"sectionTitle": "Getting Started",
|
||||
"children": [
|
||||
{
|
||||
"title": "Introduction",
|
||||
"link": "/docs/home",
|
||||
"file": "/home.md"
|
||||
},
|
||||
{
|
||||
"title": "Get Started",
|
||||
"link": "/docs/quick-start",
|
||||
"file": "/quick-start.md"
|
||||
},
|
||||
{
|
||||
"title": "Concepts",
|
||||
"link": "/docs/concepts",
|
||||
"file": "/concepts.md"
|
||||
},
|
||||
|
||||
{
|
||||
"title": "Deployment",
|
||||
"link": "/docs/deployment",
|
||||
"file": "/deployment.md"
|
||||
},
|
||||
{
|
||||
"title": "Databases",
|
||||
"link": "/docs/database",
|
||||
"file": "/database.md"
|
||||
},
|
||||
{
|
||||
"title": "Access Control",
|
||||
"link": "/docs/rbac",
|
||||
"file": "/rbac.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sectionTitle": "Guides",
|
||||
"children": [
|
||||
{
|
||||
"title": "Setup Environment",
|
||||
"link": "/docs/environment-vars",
|
||||
"file": "/environment-vars.md"
|
||||
},
|
||||
{
|
||||
"title": "Use Badges",
|
||||
"link": "/docs/status-badges",
|
||||
"file": "/status-badges.md"
|
||||
},
|
||||
{
|
||||
"title": "Setup Monitors",
|
||||
"link": "/docs/monitors",
|
||||
"file": "/monitors.md"
|
||||
},
|
||||
|
||||
{
|
||||
"title": "Setup Triggers",
|
||||
"link": "/docs/triggers",
|
||||
"file": "/triggers.md"
|
||||
},
|
||||
{
|
||||
"title": "Setup Site",
|
||||
"link": "/docs/site",
|
||||
"file": "/site.md"
|
||||
},
|
||||
{
|
||||
"title": "Setup SEO",
|
||||
"link": "/docs/seo",
|
||||
"file": "/seo.md"
|
||||
},
|
||||
{
|
||||
"title": "Setup Home",
|
||||
"link": "/docs/home-page",
|
||||
"file": "/home-page.md"
|
||||
},
|
||||
{
|
||||
"title": "Setup Theme",
|
||||
"link": "/docs/theme",
|
||||
"file": "/theme.md"
|
||||
},
|
||||
{
|
||||
"title": "View Alerts",
|
||||
"link": "/docs/alerts",
|
||||
"file": "/alerts.md"
|
||||
},
|
||||
{
|
||||
"title": "API Keys",
|
||||
"link": "/docs/apikeys",
|
||||
"file": "/apikeys.md"
|
||||
},
|
||||
|
||||
{
|
||||
"title": "Incident Management",
|
||||
"link": "/docs/incident-management",
|
||||
"file": "/incident-management.md"
|
||||
},
|
||||
{
|
||||
"title": "Embed",
|
||||
"link": "/docs/embed",
|
||||
"file": "/embed.md"
|
||||
},
|
||||
{
|
||||
"title": "Custom JS/CSS",
|
||||
"link": "/docs/custom-js-css-guide",
|
||||
"file": "/custom-js-css-guide.md"
|
||||
},
|
||||
{
|
||||
"title": "Internationalization",
|
||||
"link": "/docs/i18n",
|
||||
"file": "/i18n.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sectionTitle": "Monitors",
|
||||
"children": [
|
||||
{
|
||||
"title": "API/Website Monitor",
|
||||
"link": "/docs/monitors-api",
|
||||
"file": "/monitors-api.md"
|
||||
},
|
||||
{
|
||||
"title": "Ping Monitor",
|
||||
"link": "/docs/monitors-ping",
|
||||
"file": "/monitors-ping.md"
|
||||
},
|
||||
{
|
||||
"title": "TCP Monitor",
|
||||
"link": "/docs/monitors-tcp",
|
||||
"file": "/monitors-tcp.md"
|
||||
},
|
||||
{
|
||||
"title": "DNS Monitor",
|
||||
"link": "/docs/monitors-dns",
|
||||
"file": "/monitors-dns.md"
|
||||
},
|
||||
{
|
||||
"title": "Group Monitor",
|
||||
"link": "/docs/monitors-group",
|
||||
"file": "/monitors-group.md"
|
||||
},
|
||||
{
|
||||
"title": "SSL Monitor",
|
||||
"link": "/docs/monitors-ssl",
|
||||
"file": "/monitors-ssl.md"
|
||||
},
|
||||
{
|
||||
"title": "SQL Monitor",
|
||||
"link": "/docs/monitors-sql",
|
||||
"file": "/monitors-sql.md"
|
||||
},
|
||||
{
|
||||
"title": "Heartbeat Monitor",
|
||||
"link": "/docs/monitors-heartbeat",
|
||||
"file": "/monitors-heartbeat.md"
|
||||
},
|
||||
{
|
||||
"title": "Gamedig Monitor",
|
||||
"link": "/docs/monitors-gamedig",
|
||||
"file": "/monitors-gamedig.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sectionTitle": "API Reference",
|
||||
"children": [
|
||||
{
|
||||
"title": "Kener APIs",
|
||||
"link": "/docs/kener-apis",
|
||||
"file": "/kener-apis.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sectionTitle": "Help",
|
||||
"children": [
|
||||
{
|
||||
"title": "Fonts",
|
||||
"link": "/docs/custom-fonts",
|
||||
"file": "/custom-fonts.md"
|
||||
},
|
||||
{
|
||||
"title": "Changelogs",
|
||||
"link": "/docs/changelogs",
|
||||
"file": "/changelogs.md"
|
||||
},
|
||||
{
|
||||
"title": "Roadmap",
|
||||
"link": "/docs/roadmap",
|
||||
"file": "/roadmap.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Document</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: #111;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script
|
||||
async
|
||||
src="http://localhost:3000/embed/monitor-earth/js?theme=light&monitor=http://localhost:3000/embed/monitor-earth&locale=fr&tz=Asia/Tokyo"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Automatically set PUBLIC_WHITE_LABEL based on WHITE_LABEL
|
||||
export PUBLIC_WHITE_LABEL="${WHITE_LABEL}"
|
||||
|
||||
# Replace shell with the given command (from CMD or runtime args)
|
||||
exec "$@"
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler",
|
||||
"noImplicitAny": false
|
||||
}
|
||||
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias and https://kit.svelte.dev/docs/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-nocheck
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
@@ -8,7 +7,16 @@ const databaseURLParts = databaseURL.split("://");
|
||||
const databaseType = databaseURLParts[0];
|
||||
const databasePath = databaseURLParts[1];
|
||||
|
||||
const knexOb = {
|
||||
interface KnexConfig {
|
||||
migrations: { directory: string };
|
||||
seeds: { directory: string };
|
||||
databaseType: string;
|
||||
client?: string;
|
||||
connection?: string | { filename: string };
|
||||
useNullAsDefault?: boolean;
|
||||
}
|
||||
|
||||
const knexOb: KnexConfig = {
|
||||
migrations: {
|
||||
directory: "./migrations",
|
||||
},
|
||||
@@ -1,99 +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.js";
|
||||
import { GetSiteMap } from "./src/lib/server/controllers/controller.js";
|
||||
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 || "";
|
||||
|
||||
const app = express();
|
||||
const db = knex(knexOb);
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.path.startsWith("/embed")) {
|
||||
res.setHeader("Content-Security-Policy", "frame-ancestors *");
|
||||
}
|
||||
res.setHeader("X-Powered-By", "Kener");
|
||||
next();
|
||||
});
|
||||
app.get(base + "/healthcheck", (req, res) => {
|
||||
res.end("ok");
|
||||
});
|
||||
app.get(base + "/sitemap.xml", async (req, res) => {
|
||||
res.header("Content-Type", "application/xml");
|
||||
res.send(await GetSiteMap());
|
||||
});
|
||||
//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"
|
||||
})
|
||||
);
|
||||
} 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 + "!");
|
||||
});
|
||||
@@ -1,135 +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");
|
||||
@@ -0,0 +1,21 @@
|
||||
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
|
||||
table.string("mime_type", 50).notNullable(); // image/png, image/jpeg, image/svg+xml
|
||||
table.string("original_name", 255); // original filename
|
||||
table.integer("width"); // image width after resize
|
||||
table.integer("height"); // image height after resize
|
||||
table.integer("size"); // size in bytes
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists("images");
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// Create pages table
|
||||
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
|
||||
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"]);
|
||||
|
||||
// Foreign key constraints
|
||||
table.foreign("page_id").references("id").inTable("pages").onDelete("CASCADE");
|
||||
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
|
||||
});
|
||||
}
|
||||
|
||||
// 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> {
|
||||
await knex.schema.dropTableIfExists("pages_monitors");
|
||||
await knex.schema.dropTableIfExists("pages");
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
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());
|
||||
});
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
table.foreign("maintenance_id").references("id").inTable("maintenances").onDelete("CASCADE");
|
||||
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
|
||||
|
||||
table.unique(["maintenance_id", "monitor_tag"]);
|
||||
});
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
table.foreign("maintenance_id").references("id").inTable("maintenances").onDelete("CASCADE");
|
||||
});
|
||||
}
|
||||
|
||||
// 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)",
|
||||
"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> {
|
||||
await knex.schema.dropTableIfExists("maintenances_events");
|
||||
await knex.schema.dropTableIfExists("maintenance_monitors");
|
||||
await knex.schema.dropTableIfExists("maintenances");
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// 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> {
|
||||
// Re-add unique constraint to monitors.name
|
||||
await knex.schema.alterTable("monitors", (table) => {
|
||||
table.unique(["name"]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
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> {
|
||||
await knex.schema.alterTable("monitors", (table) => {
|
||||
table.dropColumn("is_hidden");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
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> {
|
||||
await knex.schema.alterTable("monitors", (table) => {
|
||||
table.dropColumn("monitor_settings_json");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasTable("maintenance_monitors"))) return;
|
||||
const hasCol = await knex.schema.hasColumn("maintenance_monitors", "monitor_impact");
|
||||
if (!hasCol) {
|
||||
await knex.schema.alterTable("maintenance_monitors", (table) => {
|
||||
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");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// Create monitor_alerts_config table
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
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"]);
|
||||
|
||||
// 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> {
|
||||
// Drop junction table first due to foreign key constraints
|
||||
await knex.schema.dropTableIfExists("monitor_alerts_config_triggers");
|
||||
await knex.schema.dropTableIfExists("monitor_alerts_config");
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
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"]);
|
||||
});
|
||||
}
|
||||
|
||||
// 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.dropTableIfExists("monitor_alerts_v2");
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// 1. Create subscriber_users table - the actual user identity
|
||||
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"]);
|
||||
});
|
||||
}
|
||||
|
||||
// 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());
|
||||
});
|
||||
}
|
||||
|
||||
// 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 */
|
||||
}
|
||||
|
||||
// 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());
|
||||
});
|
||||
}
|
||||
|
||||
// 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> {
|
||||
await knex.schema.dropTableIfExists("user_subscriptions_v2");
|
||||
await knex.schema.dropTableIfExists("subscriber_methods");
|
||||
await knex.schema.dropTableIfExists("subscriber_users");
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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");
|
||||
table.text("template_html_body");
|
||||
table.text("template_text_body");
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists("general_email_templates");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
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> {
|
||||
await knex.schema.alterTable("monitors", (table) => {
|
||||
table.dropColumn("external_url");
|
||||
});
|
||||
await knex.schema.alterTable("monitoring_data", (table) => {
|
||||
table.dropColumn("error_message");
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// 1. Roles table
|
||||
if (!(await knex.schema.hasTable("roles"))) {
|
||||
await knex.schema.createTable("roles", (table) => {
|
||||
table.string("id", 100).primary();
|
||||
table.text("role_name").notNullable();
|
||||
table.integer("readonly").notNullable().defaultTo(0);
|
||||
table.string("status", 20).notNullable().defaultTo("ACTIVE");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Permissions table
|
||||
if (!(await knex.schema.hasTable("permissions"))) {
|
||||
await knex.schema.createTable("permissions", (table) => {
|
||||
table.string("id", 100).primary();
|
||||
table.text("permission_name").notNullable();
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Roles ↔ Permissions junction table
|
||||
if (!(await knex.schema.hasTable("roles_permissions"))) {
|
||||
await knex.schema.createTable("roles_permissions", (table) => {
|
||||
table.string("roles_id", 100).notNullable().references("id").inTable("roles").onDelete("CASCADE");
|
||||
table.string("permissions_id", 100).notNullable().references("id").inTable("permissions").onDelete("CASCADE");
|
||||
table.string("status", 20).notNullable().defaultTo("ACTIVE");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
table.primary(["roles_id", "permissions_id"]);
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Users ↔ Roles junction table
|
||||
if (!(await knex.schema.hasTable("users_roles"))) {
|
||||
await knex.schema.createTable("users_roles", (table) => {
|
||||
table.string("roles_id", 100).notNullable().references("id").inTable("roles").onDelete("CASCADE");
|
||||
table.integer("users_id").unsigned().notNullable().references("id").inTable("users").onDelete("CASCADE");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
table.primary(["roles_id", "users_id"]);
|
||||
table.index("users_id", "idx_users_roles_users_id");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists("users_roles");
|
||||
await knex.schema.dropTableIfExists("roles_permissions");
|
||||
await knex.schema.dropTableIfExists("permissions");
|
||||
await knex.schema.dropTableIfExists("roles");
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
// Maps the legacy users.role string to the new roles.id value.
|
||||
// The old default was "user"; everything unmapped falls back to "member".
|
||||
const ROLE_MAP: Record<string, string> = {
|
||||
admin: "admin",
|
||||
editor: "editor",
|
||||
member: "member",
|
||||
user: "member",
|
||||
};
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasColumn = await knex.schema.hasColumn("users", "role");
|
||||
if (!hasColumn) return;
|
||||
|
||||
// 1. Ensure the three target roles exist so FK inserts succeed.
|
||||
// Seeds will reconcile permissions later; we only need the rows.
|
||||
const rolesToEnsure = [
|
||||
{ id: "admin", role_name: "Administrator" },
|
||||
{ id: "editor", role_name: "Editor" },
|
||||
{ id: "member", role_name: "Member" },
|
||||
];
|
||||
for (const role of rolesToEnsure) {
|
||||
const exists = await knex("roles").where("id", role.id).first();
|
||||
if (!exists) {
|
||||
await knex("roles").insert({
|
||||
id: role.id,
|
||||
role_name: role.role_name,
|
||||
readonly: 1,
|
||||
status: "ACTIVE",
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Read users.role into memory BEFORE dropping the column.
|
||||
// On SQLite, dropColumn recreates the table (create → copy → drop → rename),
|
||||
// which can discard DML inserts to tables with FKs pointing at users.
|
||||
const users: Array<{ id: number; role: string }> = await knex("users").select("id", "role");
|
||||
|
||||
// 3. Drop the column first.
|
||||
await knex.schema.alterTable("users", (table) => {
|
||||
table.dropColumn("role");
|
||||
});
|
||||
|
||||
// 4. Now populate users_roles from the in-memory snapshot.
|
||||
for (const user of users) {
|
||||
const newRoleId = ROLE_MAP[user.role] ?? "member";
|
||||
|
||||
const alreadyAssigned = await knex("users_roles").where({ roles_id: newRoleId, users_id: user.id }).first();
|
||||
|
||||
if (!alreadyAssigned) {
|
||||
await knex("users_roles").insert({
|
||||
roles_id: newRoleId,
|
||||
users_id: user.id,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse map: pick the highest-precedence role when backfilling.
|
||||
const REVERSE_ROLE_PRECEDENCE: string[] = ["admin", "editor", "member"];
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasColumn = await knex.schema.hasColumn("users", "role");
|
||||
if (!hasColumn) {
|
||||
await knex.schema.alterTable("users", (table) => {
|
||||
table.string("role").defaultTo("member");
|
||||
});
|
||||
}
|
||||
|
||||
// Backfill users.role from users_roles using deterministic precedence
|
||||
const assignments: Array<{ users_id: number; roles_id: string }> = await knex("users_roles").select(
|
||||
"users_id",
|
||||
"roles_id",
|
||||
);
|
||||
|
||||
// Group roles by user
|
||||
const userRolesMap = new Map<number, string[]>();
|
||||
for (const row of assignments) {
|
||||
const list = userRolesMap.get(row.users_id) || [];
|
||||
list.push(row.roles_id);
|
||||
userRolesMap.set(row.users_id, list);
|
||||
}
|
||||
|
||||
// Pick highest-precedence role for each user
|
||||
for (const [userId, roleIds] of userRolesMap) {
|
||||
const bestRole = REVERSE_ROLE_PRECEDENCE.find((r) => roleIds.includes(r)) || roleIds[0] || "member";
|
||||
await knex("users").where("id", userId).update({ role: bestRole });
|
||||
}
|
||||
}
|
||||
-804
@@ -1,804 +0,0 @@
|
||||
---
|
||||
info:
|
||||
title: Kener API
|
||||
version: 3.0.0
|
||||
description: |
|
||||
# Kener Selfhost node js status page
|
||||

|
||||
API specification for Kener status page and incident management system. This API spec was created using [Frogment](https://www.frogment.app)
|
||||
contact:
|
||||
name: Raj Nandan Sharma
|
||||
email: rajnandan1@gmail.com
|
||||
url: https://github.com/rajnandan1/kener/issues
|
||||
license:
|
||||
name: MIT
|
||||
url: https://opensource.org/licenses/MIT
|
||||
openapi: 3.0.0
|
||||
servers:
|
||||
- url: https://{host}
|
||||
description: Kener host URL
|
||||
tags:
|
||||
- name: Monitors
|
||||
description: APIs to interact with monitors
|
||||
- name: Incidents
|
||||
description: APIs to integrate incidents
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: enter your api key here
|
||||
schemas:
|
||||
MonitorStatus:
|
||||
type: object
|
||||
description: Monitor Status
|
||||
required:
|
||||
- status
|
||||
- latency
|
||||
- tag
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: UP
|
||||
enum:
|
||||
- UP
|
||||
- DOWN
|
||||
- DEGRADED
|
||||
latency:
|
||||
type: number
|
||||
description: In seconds
|
||||
example: 100
|
||||
timestampInSeconds:
|
||||
type: integer
|
||||
description: UTC timestamp in seconds
|
||||
example: 1731251760
|
||||
tag:
|
||||
type: string
|
||||
example: earth
|
||||
description: Tag of a monitor
|
||||
example:
|
||||
status: UP
|
||||
timestampInSeconds: 1731251760
|
||||
latency: 100
|
||||
tag: earth
|
||||
StatusResponse:
|
||||
type: object
|
||||
description: Status of a monitor given a tag
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: UP
|
||||
enum:
|
||||
- UP
|
||||
- DOWN
|
||||
- DEGRADED
|
||||
uptime:
|
||||
type: string
|
||||
example: '100'
|
||||
last_updated_at:
|
||||
type: integer
|
||||
example: 1731251760
|
||||
example:
|
||||
status: UP
|
||||
last_updated_at: 1731251760
|
||||
uptime: '100'
|
||||
Incident:
|
||||
type: object
|
||||
description: body of an incident
|
||||
properties:
|
||||
start_date_time:
|
||||
type: integer
|
||||
description: UTC timestamp in seconds
|
||||
example: 1731901920
|
||||
end_date_time:
|
||||
type: integer
|
||||
description: UTC timestamp in seconds
|
||||
nullable: true
|
||||
example: 1704123938
|
||||
title:
|
||||
type: string
|
||||
example: Outage in mumbai
|
||||
description: title of the incident
|
||||
created_at:
|
||||
type: string
|
||||
example: '2025-01-09 04:12:01'
|
||||
description: created at time
|
||||
updated_at:
|
||||
type: string
|
||||
example: '2025-01-09 06:10:00'
|
||||
description: updated at time
|
||||
status:
|
||||
type: string
|
||||
example: OPEN
|
||||
description: delete or not deleted incident
|
||||
enum:
|
||||
- OPEN
|
||||
- CLOSED
|
||||
state:
|
||||
type: string
|
||||
description: the current status of the incident
|
||||
example: INVESTIGATING
|
||||
enum:
|
||||
- INVESTIGATING
|
||||
- IDENTIFIED
|
||||
- MONITORING
|
||||
- RESOLVED
|
||||
example:
|
||||
start_date_time: 1731901920
|
||||
end_date_time: 1704123938
|
||||
title: title of the incident
|
||||
created_at: '2025-01-09 04:12:01'
|
||||
updated_at: '2025-01-09 04:12:01'
|
||||
state: INVESTIGATING
|
||||
status: OPEN
|
||||
id: 4
|
||||
IncidentResponse:
|
||||
description: Incident response schema
|
||||
allOf:
|
||||
- "$ref": "#/components/schemas/Incident"
|
||||
Comment:
|
||||
type: object
|
||||
description: Comment of an incident
|
||||
properties:
|
||||
comment:
|
||||
type: string
|
||||
example: comment 1
|
||||
commented_at:
|
||||
type: integer
|
||||
example: 1736398336
|
||||
state:
|
||||
type: string
|
||||
example: IDENTIFIED
|
||||
example:
|
||||
body: comment 2
|
||||
commented_at: 1736398336
|
||||
state: IDENTIFIED
|
||||
CommentResponse:
|
||||
type: object
|
||||
description: Comment Response
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
description: ID of the comment
|
||||
example: 60
|
||||
comment:
|
||||
type: string
|
||||
description: The content of the comment
|
||||
example: Sometimes, you want all the goodness of moment#from but you don't
|
||||
want to have to create two moments, you just want to display a length
|
||||
of time.
|
||||
incident_id:
|
||||
type: integer
|
||||
description: ID of the associated incident
|
||||
example: 24
|
||||
commented_at:
|
||||
type: integer
|
||||
description: Timestamp when the comment was added
|
||||
example: 1736398336
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Timestamp when the comment was created in ISO 8601 format
|
||||
example: '2025-01-09 04:52:16'
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Timestamp when the comment was last updated in ISO 8601 format
|
||||
example: '2025-01-09 04:52:16'
|
||||
status:
|
||||
type: string
|
||||
description: Current status of the comment
|
||||
example: ACTIVE
|
||||
state:
|
||||
type: string
|
||||
description: the current status of the incident
|
||||
example: INVESTIGATING
|
||||
enum:
|
||||
- INVESTIGATING
|
||||
- IDENTIFIED
|
||||
- MONITORING
|
||||
- RESOLVED
|
||||
example:
|
||||
id: 60
|
||||
comment: Sometimes, you want all the goodness of moment
|
||||
incident_id: 24
|
||||
commented_at: 1736398336
|
||||
created_at: '2025-01-09 04:52:16'
|
||||
updated_at: '2025-01-09 04:52:16'
|
||||
status: ACTIVE
|
||||
state: MONITORING
|
||||
IncidentStatus:
|
||||
type: object
|
||||
description: Status of the incident
|
||||
properties:
|
||||
isIdentified:
|
||||
type: boolean
|
||||
description: Has the incident been indetified
|
||||
example: true
|
||||
isResolved:
|
||||
type: boolean
|
||||
description: has the incident been resolved
|
||||
example: true
|
||||
endDatetime:
|
||||
type: integer
|
||||
description: Incident end time
|
||||
example: 1731901920
|
||||
example:
|
||||
isIdentified: true
|
||||
isResolved: true
|
||||
endDatetime: 1731901920
|
||||
IncidentCreateRequest:
|
||||
title: Incident Create Request Body
|
||||
type: object
|
||||
description: body of an incident
|
||||
required:
|
||||
- title
|
||||
- start_date_time
|
||||
properties:
|
||||
start_date_time:
|
||||
type: integer
|
||||
description: UTC timestamp in seconds
|
||||
example: 1731901920
|
||||
title:
|
||||
type: string
|
||||
example: Outage in mumbai
|
||||
description: title of the incident
|
||||
example:
|
||||
start_date_time: 1731901920
|
||||
title: title of the incident
|
||||
responses:
|
||||
Response401:
|
||||
description: Bad API keys response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
description: Invalid token response
|
||||
example: invalid token
|
||||
example:
|
||||
error: invalid token
|
||||
Response400:
|
||||
description: bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
description: bad request while calling kener apis
|
||||
example: unknown tags
|
||||
example:
|
||||
error: unknown tags
|
||||
examples:
|
||||
GetMonitorStatusExample200:
|
||||
summary: response of get status of a monitor
|
||||
description: Some Description
|
||||
value:
|
||||
status: UP
|
||||
uptime: '100'
|
||||
lastUpdatedAt: 1731901920
|
||||
DegradedRequestBody:
|
||||
summary: update to degraded
|
||||
description: Some Description
|
||||
value:
|
||||
status: DEGRADED
|
||||
timestampInSeconds: 1731251760
|
||||
latency: 100
|
||||
tag: earth
|
||||
CreateIncidentResponse:
|
||||
summary: create incident response
|
||||
description: create incident response
|
||||
value:
|
||||
start_date_time: 1731901920
|
||||
end_date_time: 1731901920
|
||||
id: 4
|
||||
created_at: '2025-01-09 04:12:01'
|
||||
updated_at: '2025-01-10 04:12:01'
|
||||
title: title of the incident
|
||||
status: OPEN
|
||||
state: INVESTIGATING
|
||||
CreateIncidentRequest:
|
||||
summary: create incident request body
|
||||
description: create incident request body
|
||||
value:
|
||||
start_date_time: 1731901920
|
||||
title: title of the incident
|
||||
SearchIncidentsResponse:
|
||||
summary: array of incidents
|
||||
description: Some Description
|
||||
value:
|
||||
- id: 2
|
||||
title: future wala
|
||||
start_date_time: 1736774486
|
||||
end_date_time: 1737033787
|
||||
created_at: '2025-01-12 13:21:32'
|
||||
updated_at: '2025-01-12 13:23:15'
|
||||
status: OPEN
|
||||
state: RESOLVED
|
||||
- id: 1
|
||||
title: internatiinal asda
|
||||
start_date_time: 1736684329
|
||||
end_date_time:
|
||||
created_at: '2025-01-12 12:18:53'
|
||||
updated_at: '2025-01-12 13:21:32'
|
||||
status: OPEN
|
||||
state: INVESTIGATING
|
||||
CommentsResponse:
|
||||
summary: list of comments
|
||||
description: Some Description
|
||||
value:
|
||||
- id: 58
|
||||
comment: idensad
|
||||
incident_id: 24
|
||||
commented_at: 1736398295
|
||||
created_at: '2025-01-09 04:51:37'
|
||||
updated_at: '2025-01-09 04:51:37'
|
||||
status: ACTIVE
|
||||
state: IDENTIFIED
|
||||
- id: 57
|
||||
comment: Sometimes, you want all the goodness of moment#from but you don't
|
||||
want to have to create two moments, you just want to display a length of
|
||||
time.
|
||||
incident_id: 24
|
||||
commented_at: 1736398279
|
||||
created_at: '2025-01-09 04:51:19'
|
||||
updated_at: '2025-01-09 04:51:19'
|
||||
status: ACTIVE
|
||||
state: INVESTIGATING
|
||||
CommentRequestBody:
|
||||
summary: request body for a update
|
||||
description: request body for a update to add in an incident
|
||||
value:
|
||||
comment: This is a comment
|
||||
commented_at: 1736398336
|
||||
state: IDENTIFIED
|
||||
CreateCommentResponse:
|
||||
summary: create update response body sample
|
||||
description: create update response body sample
|
||||
value:
|
||||
id: 60
|
||||
comment: Sometimes, you want all the goodness of moment
|
||||
incident_id: 24
|
||||
commented_at: 1736398336
|
||||
created_at: '2025-01-09 04:52:16'
|
||||
updated_at: '2025-01-09 04:52:16'
|
||||
status: ACTIVE
|
||||
state: MONITORING
|
||||
paths:
|
||||
"/api/status":
|
||||
post:
|
||||
operationId: updateMonitorStatus
|
||||
summary: Update status of a monitor
|
||||
description: Update status of a monitor at a given timestamp UTC
|
||||
tags:
|
||||
- Monitors
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
description: request body to update status of a monitor
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/MonitorStatus"
|
||||
examples:
|
||||
degraded:
|
||||
"$ref": "#/components/examples/DegradedRequestBody"
|
||||
responses:
|
||||
'200':
|
||||
description: Status updated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: integer
|
||||
example: 200
|
||||
message:
|
||||
type: string
|
||||
example: success at 1731251760
|
||||
example:
|
||||
status: 200
|
||||
message: success at 1731251760
|
||||
'400':
|
||||
"$ref": "#/components/responses/Response400"
|
||||
'401':
|
||||
"$ref": "#/components/responses/Response401"
|
||||
get:
|
||||
operationId: getMonitorStatus
|
||||
summary: Get status of a monitor
|
||||
description: get status of a monitor at timestamp
|
||||
tags:
|
||||
- Monitors
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: tag
|
||||
in: query
|
||||
required: true
|
||||
description: monitor tag to get status of it
|
||||
schema:
|
||||
type: string
|
||||
example: earth
|
||||
responses:
|
||||
'200':
|
||||
description: Monitor status retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/StatusResponse"
|
||||
examples:
|
||||
successExample:
|
||||
"$ref": "#/components/examples/GetMonitorStatusExample200"
|
||||
'400':
|
||||
"$ref": "#/components/responses/Response400"
|
||||
'401':
|
||||
"$ref": "#/components/responses/Response401"
|
||||
"/api/incident":
|
||||
post:
|
||||
operationId: createIncident
|
||||
summary: Create a new incident
|
||||
description: API to create incidents
|
||||
tags:
|
||||
- Incidents
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
description: request body to manually create an incident
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/IncidentCreateRequest"
|
||||
examples:
|
||||
sample:
|
||||
"$ref": "#/components/examples/CreateIncidentRequest"
|
||||
responses:
|
||||
'200':
|
||||
description: Incident created successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/IncidentResponse"
|
||||
examples:
|
||||
success:
|
||||
"$ref": "#/components/examples/CreateIncidentResponse"
|
||||
'400':
|
||||
"$ref": "#/components/responses/Response400"
|
||||
'401':
|
||||
"$ref": "#/components/responses/Response401"
|
||||
get:
|
||||
operationId: searchIncidents
|
||||
summary: Search for incidents
|
||||
description: API to get incidents
|
||||
tags:
|
||||
- Incidents
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: status
|
||||
in: query
|
||||
description: status of the incident. Can be open or close. close means deleted
|
||||
schema:
|
||||
type: string
|
||||
description: status of the incident. Can be open or close. close means deleted
|
||||
enum:
|
||||
- OPEN
|
||||
- CLOSED
|
||||
default: OPEN
|
||||
example: OPEN
|
||||
- name: state
|
||||
in: query
|
||||
description: state of the incident. Can be open or close
|
||||
schema:
|
||||
type: string
|
||||
description: state of the incident. Can be open or close
|
||||
enum:
|
||||
- INVESTIGATING
|
||||
- IDENTIFIED
|
||||
- MONITORING
|
||||
- RESOLVED
|
||||
default: INVESTIGATING
|
||||
example: IDENTIFIED
|
||||
- name: page
|
||||
in: query
|
||||
description: page number
|
||||
schema:
|
||||
type: integer
|
||||
description: page number
|
||||
default: 1
|
||||
minimum: 1
|
||||
example: 1
|
||||
- name: limit
|
||||
in: query
|
||||
description: how many per page
|
||||
schema:
|
||||
type: integer
|
||||
default: 10
|
||||
description: how many per page
|
||||
maximum: 100
|
||||
example: 10
|
||||
- name: start_date_time
|
||||
description: start time
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
description: start time
|
||||
example: 1731866475
|
||||
- name: end_date_time
|
||||
description: end time
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
description: end time
|
||||
example: 1731866475
|
||||
responses:
|
||||
'200':
|
||||
description: Search results retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
"$ref": "#/components/schemas/IncidentResponse"
|
||||
examples:
|
||||
success:
|
||||
"$ref": "#/components/examples/SearchIncidentsResponse"
|
||||
'400':
|
||||
"$ref": "#/components/responses/Response400"
|
||||
'401':
|
||||
"$ref": "#/components/responses/Response401"
|
||||
"/api/incident/{incident_id}":
|
||||
parameters:
|
||||
- name: incident_id
|
||||
in: path
|
||||
required: true
|
||||
description: incident id as an integer to get incident by id
|
||||
schema:
|
||||
type: integer
|
||||
example: 4
|
||||
get:
|
||||
operationId: getIncident
|
||||
summary: Get an incident by id
|
||||
description: API to get an incident by incident id
|
||||
tags:
|
||||
- Incidents
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Incident retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/IncidentResponse"
|
||||
examples:
|
||||
success:
|
||||
"$ref": "#/components/examples/CreateIncidentResponse"
|
||||
'400':
|
||||
"$ref": "#/components/responses/Response400"
|
||||
'401':
|
||||
"$ref": "#/components/responses/Response401"
|
||||
patch:
|
||||
operationId: updateIncident
|
||||
summary: Update an incident
|
||||
description: API to update an incident by incident id
|
||||
tags:
|
||||
- Incidents
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
description: search for an incident
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/Incident"
|
||||
examples:
|
||||
sample:
|
||||
"$ref": "#/components/examples/CreateIncidentRequest"
|
||||
responses:
|
||||
'200':
|
||||
description: Incident updated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/IncidentResponse"
|
||||
examples:
|
||||
success:
|
||||
"$ref": "#/components/examples/CreateIncidentResponse"
|
||||
'400':
|
||||
"$ref": "#/components/responses/Response400"
|
||||
'401':
|
||||
"$ref": "#/components/responses/Response401"
|
||||
"/api/incident/{incident_id}/updates":
|
||||
parameters:
|
||||
- name: incident_id
|
||||
in: path
|
||||
required: true
|
||||
description: incident id to fetch comment for
|
||||
schema:
|
||||
type: integer
|
||||
example: 4
|
||||
post:
|
||||
operationId: addIncidentComment
|
||||
summary: Add an update to an incident
|
||||
description: API to create update for an incident
|
||||
tags:
|
||||
- Incidents
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
description: body to add an update
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/Comment"
|
||||
examples:
|
||||
sample:
|
||||
"$ref": "#/components/examples/CommentRequestBody"
|
||||
responses:
|
||||
'200':
|
||||
description: Comment added successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/CommentResponse"
|
||||
examples:
|
||||
sample:
|
||||
"$ref": "#/components/examples/CreateCommentResponse"
|
||||
'400':
|
||||
"$ref": "#/components/responses/Response400"
|
||||
'401':
|
||||
"$ref": "#/components/responses/Response401"
|
||||
get:
|
||||
operationId: getIncidentComments
|
||||
summary: Get updates for an incident
|
||||
description: API to get updates for an incident
|
||||
tags:
|
||||
- Incidents
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: updates retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
"$ref": "#/components/schemas/CommentResponse"
|
||||
examples:
|
||||
success:
|
||||
"$ref": "#/components/examples/CommentsResponse"
|
||||
'400':
|
||||
"$ref": "#/components/responses/Response400"
|
||||
'401':
|
||||
"$ref": "#/components/responses/Response401"
|
||||
"/api/incident/{incident_id}/monitors":
|
||||
parameters:
|
||||
- name: incident_id
|
||||
in: path
|
||||
required: true
|
||||
description: incident id to fetch comment for
|
||||
schema:
|
||||
type: integer
|
||||
example: 4
|
||||
get:
|
||||
summary: Get monitors for an incident
|
||||
operationId: getIncidentMonitors
|
||||
tags:
|
||||
- Incidents
|
||||
parameters:
|
||||
- name: incident_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
description: The ID of the incident
|
||||
responses:
|
||||
'200':
|
||||
description: List of monitors for the incident
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
monitor_tag:
|
||||
type: string
|
||||
description: The tag of the monitor
|
||||
example: earth
|
||||
monitor_impact:
|
||||
type: string
|
||||
description: The impact status of the monitor
|
||||
example: DOWN
|
||||
security:
|
||||
- bearerAuth: []
|
||||
post:
|
||||
summary: Add a monitor to an incident
|
||||
operationId: addIncidentMonitor
|
||||
tags:
|
||||
- Incidents
|
||||
parameters:
|
||||
- name: incident_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
description: The ID of the incident
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
tag:
|
||||
type: string
|
||||
description: The tag for the monitor
|
||||
example: okbookmarks
|
||||
impact:
|
||||
type: string
|
||||
description: The impact status of the monitor
|
||||
example: DEGRADED
|
||||
responses:
|
||||
'200':
|
||||
description: Monitor added successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
monitor_tag:
|
||||
type: string
|
||||
description: The tag of the monitor
|
||||
example: okbookmarks
|
||||
monitor_impact:
|
||||
type: string
|
||||
description: The impact status of the monitor
|
||||
example: DEGRADED
|
||||
security:
|
||||
- bearerAuth: []
|
||||
delete:
|
||||
summary: Delete a monitor from an incident
|
||||
operationId: deleteIncidentMonitor
|
||||
tags:
|
||||
- Incidents
|
||||
parameters:
|
||||
- name: incident_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
description: The ID of the incident
|
||||
- name: tag
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The tag of the monitor to delete
|
||||
responses:
|
||||
'200':
|
||||
description: Monitor deleted successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
description: Confirmation message
|
||||
example: Monitor deleted successfully
|
||||
security:
|
||||
- bearerAuth: []
|
||||
Generated
+4981
-3538
File diff suppressed because it is too large
Load Diff
+148
-88
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "kener",
|
||||
"version": "3.2.17",
|
||||
"version": "4.0.23",
|
||||
"type": "module",
|
||||
"private": false,
|
||||
"license": "MIT",
|
||||
"description": "Kener is a modern, open-source status page application built with Node.js. It provides real-time monitoring, uptime tracking, incident management, and beautiful dashboards. Perfect for DevOps teams, SaaS providers, and businesses needing reliable service status communication with minimal setup.",
|
||||
@@ -27,100 +28,159 @@
|
||||
"url": "https://github.com/rajnandan1/kener.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"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",
|
||||
"migrate": "npx knex migrate:latest",
|
||||
"preseed": "npx knex migrate:latest",
|
||||
"seed": "npx knex seed:run",
|
||||
"predev": "npm run seed",
|
||||
"devschedule": "node src/lib/server/startup.js",
|
||||
"schedule": "node src/lib/server/startup.js",
|
||||
"development": "vite dev",
|
||||
"generate-readme": "node scripts/generate-readme.js",
|
||||
"dev": "npm-run-all --parallel devschedule development",
|
||||
"development": "vite dev",
|
||||
"devschedule": "vite-node src/lib/server/startup.ts",
|
||||
"generate-readme": "node scripts/generate-readme.js",
|
||||
"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": "vite-node scripts/fix-migration-ext.ts && npx knex migrate:latest",
|
||||
"prettify": "prettier --write .",
|
||||
"start": "node main.js"
|
||||
"preview": "vite preview",
|
||||
"schedule": "vite-node src/lib/server/startup.ts",
|
||||
"seed": "npx knex seed:run",
|
||||
"start": "node build/main.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^2.0.0",
|
||||
"@sveltejs/adapter-node": "^1.3.1",
|
||||
"@sveltejs/kit": "^1.27.4",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@zerodevx/svelte-toast": "^0.9.6",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"date-picker-svelte": "^2.15.1",
|
||||
"postcss": "^8.4.24",
|
||||
"postcss-load-config": "^4.0.1",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-svelte": "^3.2.3",
|
||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||
"svelte": "^4.0.5",
|
||||
"svelte-awesome-color-picker": "^3.1.4",
|
||||
"svelte-check": "^3.6.0",
|
||||
"svelte-dnd-action": "^0.9.55",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^4.4.2"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.26.10",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-javascript": "^6.2.3",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-markdown": "^6.3.2",
|
||||
"@formkit/auto-animate": "^0.8.2",
|
||||
"@number-flow/svelte": "^0.2.1",
|
||||
"@scalar/express-api-reference": "^0.4.167",
|
||||
"@uiw/codemirror-theme-github": "^4.23.10",
|
||||
"analytics": "^0.8.14",
|
||||
"axios": "^1.6.2",
|
||||
"badge-maker": "^3.3.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"better-sqlite3": "^11.5.0",
|
||||
"bits-ui": "^0.9.9",
|
||||
"cheerio": "^1.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"croner": "^7.0.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"dns2": "^2.1.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.18.2",
|
||||
"figlet": "^1.8.0",
|
||||
"fs-extra": "^11.1.1",
|
||||
"gamedig": "^5.3.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"knex": "^3.1.0",
|
||||
"lucide-svelte": "^0.483.0",
|
||||
"marked": "^11.1.1",
|
||||
"mobile-detect": "^1.4.5",
|
||||
"mode-watcher": "^0.4.1",
|
||||
"moment": "^2.29.4",
|
||||
"moment-timezone": "^0.5.43",
|
||||
"mustache": "^4.2.0",
|
||||
"mysql2": "^3.12.0",
|
||||
"node-cache": "^5.1.2",
|
||||
"nodemailer": "^6.10.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"pg": "^8.13.1",
|
||||
"pg-pool": "^3.7.0",
|
||||
"ping": "^0.4.4",
|
||||
"queue": "^7.0.0",
|
||||
"randomstring": "^1.3.0",
|
||||
"resend": "^4.0.1",
|
||||
"svelte-codemirror-editor": "^1.4.1",
|
||||
"svelte-legos": "^0.2.5",
|
||||
"tailwind-merge": "^2.0.0",
|
||||
"tailwind-variants": "^0.1.18",
|
||||
"vite-plugin-package-version": "^1.1.0"
|
||||
"@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.53.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/d3-shape": "^3.1.8",
|
||||
"@types/dns2": "^2.0.10",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/heic-convert": "^2.1.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/mustache": "^4.2.6",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"clsx": "^2.1.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"date-picker-svelte": "^2.17.0",
|
||||
"layerchart": "^2.0.0-next.43",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-load-config": "^6.0.1",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"svelte": "^5.53.5",
|
||||
"svelte-awesome-color-picker": "^4.1.0",
|
||||
"svelte-check": "^4.3.4",
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/lint": "^6.9.2",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.39.11",
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@humanspeak/svelte-purify": "^0.0.6",
|
||||
"@number-flow/svelte": "^0.3.9",
|
||||
"@scalar/express-api-reference": "^0.8.28",
|
||||
"@scalar/sveltekit": "^0.1.43",
|
||||
"@scaleway/random-name": "^5.1.4",
|
||||
"@uiw/codemirror-theme-github": "^4.25.3",
|
||||
"axios": "^1.13.2",
|
||||
"badge-maker": "^5.0.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"bits-ui": "^2.14.4",
|
||||
"bullmq": "^5.66.2",
|
||||
"cheerio": "^1.1.2",
|
||||
"croner": "^9.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"dns2": "^2.1.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"esbuild": "^0.27.2",
|
||||
"express": "^5.2.1",
|
||||
"figlet": "^1.9.4",
|
||||
"flexsearch": "^0.8.212",
|
||||
"front-matter": "^4.0.2",
|
||||
"gamedig": "^5.3.2",
|
||||
"glob": "^13.0.6",
|
||||
"heic-convert": "^2.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ioredis": "^5.8.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"knex": "^3.1.0",
|
||||
"lucide-svelte": "^0.561.0",
|
||||
"marked": "^17.0.1",
|
||||
"marked-alert": "^2.1.2",
|
||||
"marked-custom-heading-id": "^2.0.16",
|
||||
"marked-gfm-heading-id": "^4.1.3",
|
||||
"marked-highlight": "^2.2.3",
|
||||
"marked-plaintify": "^1.1.1",
|
||||
"mobile-detect": "^1.4.5",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"mustache": "^4.2.0",
|
||||
"mysql2": "^3.15.3",
|
||||
"nanoid": "^5.1.6",
|
||||
"node-cache": "^5.1.2",
|
||||
"nodemailer": "^7.0.11",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"pg": "^8.16.3",
|
||||
"pg-pool": "^3.10.1",
|
||||
"ping": "^1.0.0",
|
||||
"randomstring": "^1.3.1",
|
||||
"resend": "^6.6.0",
|
||||
"rrule": "^2.8.1",
|
||||
"sharp": "^0.34.5",
|
||||
"striptags": "^3.2.0",
|
||||
"style-to-object": "^1.0.14",
|
||||
"svelte-codemirror-editor": "^2.1.0",
|
||||
"vite-plugin-package-version": "^1.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"fast-xml-parser": "^5.5.6",
|
||||
"rollup": "^4.59.0",
|
||||
"undici": "^7.24.0",
|
||||
"minimatch": "^10.2.3",
|
||||
"devalue": "^5.6.4",
|
||||
"dompurify": "^3.3.2",
|
||||
"cookie": "^0.7.0",
|
||||
"mailparser": "^3.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
const tailwindcss = require("tailwindcss");
|
||||
const autoprefixer = require("autoprefixer");
|
||||
|
||||
const config = {
|
||||
plugins: [
|
||||
//Some plugins, like tailwindcss/nesting, need to run before Tailwind,
|
||||
tailwindcss(),
|
||||
//But others, like autoprefixer, need to run after,
|
||||
autoprefixer
|
||||
]
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
+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();
|
||||
@@ -1,31 +0,0 @@
|
||||
import fs from "fs";
|
||||
import Mustache from "mustache";
|
||||
import dotenv from "dotenv";
|
||||
import { fileURLToPath } from "url";
|
||||
import path from "path";
|
||||
|
||||
// Load environment variables from .env file
|
||||
dotenv.config();
|
||||
|
||||
// Resolve paths correctly in ES modules
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Load the template
|
||||
const templatePath = path.resolve(__dirname, "../README.template.md");
|
||||
const template = fs.readFileSync(templatePath, "utf-8");
|
||||
|
||||
// Load environment variables and provide default values
|
||||
const data = {
|
||||
kener_full_version: process.env.BUILD_FULL_VERSION || "N/A",
|
||||
kener_major_version: process.env.BUILD_MAJOR_VERSION || "N/A",
|
||||
kener_major_minor_version: process.env.BUILD_MAJOR_MINOR_VERSION || "N/A",
|
||||
};
|
||||
|
||||
// Render README.md
|
||||
const output = Mustache.render(template, data);
|
||||
|
||||
// Write to README.md
|
||||
fs.writeFileSync(path.resolve(__dirname, "../README.md"), output);
|
||||
|
||||
console.log("✅ README.md generated successfully!");
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import subscriptionAccountCodeTemplate from "../src/lib/server/templates/general/subscription_account_code_template.ts";
|
||||
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> {
|
||||
// Check if the table has template_id 'subscription_account_code'
|
||||
let count = await knex("general_email_templates")
|
||||
.where({ template_id: subscriptionAccountCodeTemplate.template_id })
|
||||
.count("template_id as CNT")
|
||||
.first();
|
||||
if (count && count.CNT == 0) {
|
||||
await knex("general_email_templates").insert({
|
||||
template_id: subscriptionAccountCodeTemplate.template_id,
|
||||
template_subject: subscriptionAccountCodeTemplate.template_subject,
|
||||
template_html_body: subscriptionAccountCodeTemplate.template_html_body,
|
||||
template_text_body: subscriptionAccountCodeTemplate.template_text_body,
|
||||
});
|
||||
}
|
||||
|
||||
count = await knex("general_email_templates")
|
||||
.where({ template_id: subscriptionUpdateTemplate.template_id })
|
||||
.count("template_id as CNT")
|
||||
.first();
|
||||
|
||||
if (count && count.CNT == 0) {
|
||||
await knex("general_email_templates").insert({
|
||||
template_id: subscriptionUpdateTemplate.template_id,
|
||||
template_subject: subscriptionUpdateTemplate.template_subject,
|
||||
template_html_body: subscriptionUpdateTemplate.template_html_body,
|
||||
template_text_body: subscriptionUpdateTemplate.template_text_body,
|
||||
});
|
||||
}
|
||||
count = await knex("general_email_templates")
|
||||
.where({ template_id: forgotPasswordTemplate.template_id })
|
||||
.count("template_id as CNT")
|
||||
.first();
|
||||
|
||||
if (count && count.CNT == 0) {
|
||||
await knex("general_email_templates").insert({
|
||||
template_id: forgotPasswordTemplate.template_id,
|
||||
template_subject: forgotPasswordTemplate.template_subject,
|
||||
template_html_body: forgotPasswordTemplate.template_html_body,
|
||||
template_text_body: forgotPasswordTemplate.template_text_body,
|
||||
});
|
||||
}
|
||||
count = await knex("general_email_templates")
|
||||
.where({ template_id: inviteUserTemplate.template_id })
|
||||
.count("template_id as CNT")
|
||||
.first();
|
||||
|
||||
if (count && count.CNT == 0) {
|
||||
await knex("general_email_templates").insert({
|
||||
template_id: inviteUserTemplate.template_id,
|
||||
template_subject: inviteUserTemplate.template_subject,
|
||||
template_html_body: inviteUserTemplate.template_html_body,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import monitorSeed from "../src/lib/server/db/seedMonitorData.js";
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
export async function seed(knex) {
|
||||
// Check if the table is empty
|
||||
const count = await knex("monitors").count("id as CNT").first();
|
||||
if (count.CNT == 0) {
|
||||
// Deletes ALL existing entries
|
||||
for (const monitor of monitorSeed) {
|
||||
await knex("monitors").insert({
|
||||
tag: monitor.tag,
|
||||
name: monitor.name,
|
||||
description: monitor.description,
|
||||
image: monitor.image,
|
||||
cron: monitor.cron,
|
||||
default_status: monitor.default_status,
|
||||
status: monitor.status,
|
||||
category_name: monitor.category_name,
|
||||
monitor_type: monitor.monitor_type,
|
||||
type_data: monitor.type_data,
|
||||
day_degraded_minimum_count: monitor.day_degraded_minimum_count,
|
||||
day_down_minimum_count: monitor.day_down_minimum_count,
|
||||
include_degraded_in_downtime: monitor.include_degraded_in_downtime,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import monitorSeed from "../src/lib/server/db/seedMonitorData.ts";
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function seed(knex: Knex): Promise<void> {
|
||||
// Check if the table is empty
|
||||
const count = await knex("monitors").count("id as CNT").first();
|
||||
if (count && count.CNT == 0) {
|
||||
// Deletes ALL existing entries
|
||||
for (const monitor of monitorSeed) {
|
||||
await knex("monitors").insert({
|
||||
tag: monitor.tag,
|
||||
name: monitor.name,
|
||||
description: monitor.description,
|
||||
image: monitor.image,
|
||||
cron: monitor.cron,
|
||||
default_status: monitor.default_status,
|
||||
status: monitor.status,
|
||||
category_name: monitor.category_name,
|
||||
monitor_type: monitor.monitor_type,
|
||||
type_data: monitor.type_data,
|
||||
day_degraded_minimum_count: monitor.day_degraded_minimum_count,
|
||||
day_down_minimum_count: monitor.day_down_minimum_count,
|
||||
include_degraded_in_downtime: monitor.include_degraded_in_downtime,
|
||||
is_hidden: monitor.is_hidden || "NO",
|
||||
monitor_settings_json: monitor.monitor_settings_json || null,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user