Compare commits

..

107 Commits

Author SHA1 Message Date
Raj Nandan Sharma db6cb6cf7d refactor: improve SQLite migration for monitor_alerts_config to ensure monitor_tag is nullable 2026-03-28 11:07:13 +05:30
Raj Nandan Sharma babeeb75b2 chore: update v4.0.22 changelog to note migration fix for sqlite3 from v4.0.21 2026-03-28 10:36:30 +05:30
Raj Nandan Sharma e0187605e7 chore: update changelog for v4.0.22 and remove v4.0.21 entry 2026-03-28 10:34:43 +05:30
Raj Nandan Sharma e2861f1e59 refactor: rebuild SQLite monitor_alerts_config table to make monitor_tag nullable 2026-03-28 10:32:50 +05:30
Raj Nandan Sharma 01aa4d9984 refactor: modify SQLite monitor_alerts_config to allow nullable monitor_tag 2026-03-28 09:50:08 +05:30
github-actions[bot] 555372f175 chore(release): bump version to 4.0.21 2026-03-27 17:58:55 +00:00
Raj Nandan Sharma bd5fd409d1 chore: update documentation for v4.0.21 changelog and navigation 2026-03-27 22:27:24 +05:30
Raj Nandan Sharma f61d82e13f Merge pull request #688 from rajnandan1/implement/672
refactor: implement global maintenance notification settings and upda…
2026-03-27 21:28:02 +05:30
Raj Nandan Sharma f39588fff7 refactor: enhance maintenance notification settings validation and update event status logic 2026-03-27 21:02:51 +05:30
Raj Nandan Sharma 0716f271df refactor: implement global maintenance notification settings and update related event handling 2026-03-27 20:46:00 +05:30
Raj Nandan Sharma 1085c3e561 Merge pull request #679 from rajnandan1/release-4.0.21
refactor: support multi-monitor alerts by updating alert configuratio…
2026-03-25 16:44:43 +05:30
Raj Nandan Sharma 93907e6d96 refactor: improve error handling in tcpEval and evalResp validation 2026-03-25 16:40:44 +05:30
Raj Nandan Sharma d03fd63c64 refactor: support multi-monitor alerts by updating alert configuration and database schema
- Modify alertToVariables function to accept an optional monitorTag parameter for better flexibility in alert naming.
- Update sendAlertNotifications function to pass monitorTag to alertToVariables.
- Enhance addWorker function to handle multiple monitors by querying and creating alerts with the appropriate monitorTag.
- Introduce a new junction table for many-to-many relationships between monitor alerts and monitors.
- Update database migration to accommodate the new schema and backfill existing data.
- Revise frontend components to support multiple monitor selections in alert configurations.
- Enhance documentation to include analytics provider setup instructions.

implements #675
2026-03-25 16:29:20 +05:30
Raj Nandan Sharma 3ff43af787 Merge pull request #678 from LMtx/feature/vulnerability-fixes
security: fix critical and high severity vulnerabilities
2026-03-25 08:47:08 +05:30
Raj Nandan Sharma 7f2fef8e8e refactor: correct script host URL construction in Google Tag Manager snippet 2026-03-24 11:59:34 +05:30
LMtx 7658170865 security: fix critical and high severity vulnerabilities
- Upgrade svelte to ^5.53.5 (CVE-2025-46223 SSR XSS fixes)
- Upgrade @sveltejs/kit to ^2.53.3 (deserialization DoS fixes)
- Add npm overrides for transitive dependencies:
  - fast-xml-parser ^5.5.6 (CVE-2025-46223 XXE bypass)
  - rollup ^4.59.0 (CVE-2025-46717 path traversal)
  - undici ^7.24.0 (WebSocket DoS fixes)
  - minimatch ^10.2.3 (ReDoS fixes)
  - devalue ^5.6.4 (prototype pollution fix)
  - dompurify ^3.3.2 (mXSS fix)
  - cookie ^0.7.0 (injection fix)
  - mailparser ^3.9.3 (ReDoS fix)

All npm audit checks pass with 0 vulnerabilities.
2026-03-23 11:55:45 +01:00
Raj Nandan Sharma b1f8a505a5 Merge pull request #677 from rajnandan1/maintenance-prominient
refactor: implement upcoming maintenances feature in dashboard and mo…
2026-03-23 09:19:16 +05:30
Raj Nandan Sharma 104da58646 refactor: implement upcoming maintenances feature in dashboard and monitor views. implements #665 and #674 2026-03-23 09:18:45 +05:30
Raj Nandan Sharma ed6e97e8c9 refactor: update Google Tag Manager script source and enhance configuration with transport URL 2026-03-22 23:11:43 +05:30
Raj Nandan Sharma 0b25274874 refactor: enhance Google Tag Manager integration with transport URL and script host options 2026-03-22 20:31:34 +05:30
Raj Nandan Sharma bb8ab41bfe Merge pull request #673 from danynocz/update-czech-slovakia-march
Update Czech and Slovak translation
2026-03-20 22:16:01 +05:30
_DANYNO_ b43a5fb343 Update Slovak translations 2026-03-20 15:23:22 +01:00
_DANYNO_ 554caa5018 Update Czech translation 2026-03-20 15:19:55 +01:00
Raj Nandan Sharma ffe7403043 refactor: remove unused <svelte:head> elements and associated metadata from documentation layouts 2026-03-20 16:41:58 +05:30
Raj Nandan Sharma a26d0ece59 Merge pull request #671 from rajnandan1/fixcustom-js-css-guide-link
Fixcustom js css guide link
2026-03-20 15:37:50 +05:30
Raj Nandan Sharma a4277f7ed0 refactor: update custom fonts guide link to point to the correct URL 2026-03-20 15:36:50 +05:30
Raj Nandan Sharma f75aaf9cef refactor: update custom CSS guide link to direct to the correct URL 2026-03-20 15:36:04 +05:30
Raj Nandan Sharma 4e1ecf41ee Merge pull request #670 from rajnandan1/fix-i18n-667
refactor: update "Included Monitors" label to include count in variou…
2026-03-20 15:28:35 +05:30
Raj Nandan Sharma f23fb5313c refactor: update "Included Monitors" label to include count in various components and localization files, implements #667 2026-03-20 14:48:06 +05:30
github-actions[bot] 18489c5339 chore(release): bump version to 4.0.20 2026-03-19 17:08:48 +00:00
Raj Nandan Sharma 4044bae26d chore: include v4.0.20 changelog with new features and improvements 2026-03-19 21:19:29 +05:30
Raj Nandan Sharma 02686caa78 chore: update font import in docs.css and create comparison guide for Kener vs other status pages 2026-03-19 15:09:59 +05:30
Raj Nandan Sharma 797aef80d6 Update typography styles for improved consistency and adjust font import URL 2026-03-19 13:55:32 +05:30
Raj Nandan Sharma a8841ad8a3 Implement support for HEIC/HEIF image formats and increase body size limit to 3M 2026-03-19 12:28:58 +05:30
Raj Nandan Sharma 0b2cd5fc8a Merge pull request #664 from rajnandan1/seo-opti
implement site and page-level SEO meta tags with social preview image…
2026-03-19 10:22:38 +05:30
Raj Nandan Sharma 9d349716e5 Update typography to use Bodoni Moda font for improved aesthetics 2026-03-19 10:21:35 +05:30
Raj Nandan Sharma 92d068ef49 Update font family to Inria Serif for improved typography consistency 2026-03-19 10:16:30 +05:30
Raj Nandan Sharma c6e3620151 Refactor API eval function syntax and improve clarity in examples 2026-03-19 10:09:22 +05:30
Raj Nandan Sharma d92165d0f8 resolved conflict 2026-03-19 04:18:55 +00:00
Raj Nandan Sharma a56bbead8d Merge pull request #666 from rajnandan1/sitemap-1
implement sitemap configuration and generation functionality
2026-03-19 09:38:47 +05:30
Raj Nandan Sharma db3fb923f0 implement sitemap configuration and generation functionality 2026-03-19 09:36:54 +05:30
Raj Nandan Sharma bb9d88a095 Update .prettierignore to include src/lib/components/ui directory 2026-03-18 22:19:03 +05:30
Raj Nandan Sharma 912da6b8f4 Remove unused llms.txt and sitemap.xml server handlers to streamline codebase 2026-03-18 22:17:34 +05:30
Raj Nandan Sharma 3b07623346 Implement SEO enhancements across documentation and application pages, including Open Graph and Twitter meta tags, improve robots.txt for AI crawlers, and streamline code formatting for better readability. 2026-03-18 12:20:42 +05:30
Raj Nandan Sharma 702ceca9b0 Implement Open Graph and Twitter card images for documentation page 2026-03-18 11:55:45 +05:30
Raj Nandan Sharma 21f2433919 implement SEO meta tags and social preview images for page and site configurations 2026-03-18 11:52:19 +05:30
Raj Nandan Sharma 4e7791b104 update documentation for API development and project overview, enhancing clarity on architecture and environment variables 2026-03-18 11:11:39 +05:30
Raj Nandan Sharma f79c24c80d remove outdated architecture documentation for code-context, database migrations, and group monitor cache flow 2026-03-18 10:59:40 +05:30
Raj Nandan Sharma 087c2f25fb implement site and page-level SEO meta tags with social preview image support 2026-03-18 10:56:39 +05:30
github-actions[bot] 36f2ae1f69 chore(release): bump version to 4.0.18 2026-03-17 06:14:16 +00:00
Raj Nandan Sharma dcd830eb82 implement v4.0.18 changelog with new features, improvements, and bug fixes 2026-03-17 09:12:14 +05:30
Raj Nandan Sharma 80637fd4aa Merge pull request #663 from rajnandan1/implement/662
Implements #662
2026-03-17 07:35:13 +05:30
Raj Nandan Sharma a7f0072f32 implement monitoring data deletion with optional tag and date range 2026-03-16 23:05:46 +05:30
Raj Nandan Sharma a39e676a23 implement monitoring data deletion functionality with date range support 2026-03-16 22:50:40 +05:30
Raj Nandan Sharma fbc926036e Merge pull request #661 from rajnandan1/add-incident-delete
Add incident delete
2026-03-16 17:24:14 +05:30
Raj Nandan Sharma 5b508d19bc implement incident deletion functionality and associated database updates 2026-03-16 17:20:23 +05:30
Raj Nandan Sharma 589281ce13 implement incident deletion functionality and associated database updates 2026-03-16 17:19:59 +05:30
Raj Nandan Sharma 36aec8c519 Merge pull request #659 from rajnandan1/translations-status-add
add missing translations
2026-03-16 10:59:53 +05:30
Raj Nandan Sharma 978bfb05f2 Merge pull request #660 from rajnandan1/fix-timezone-sw
refactor: update timestamp handling to respect selected timezone
2026-03-16 10:59:32 +05:30
Raj Nandan Sharma 3ffd0538a1 refactor: improve timezone handling by including UTC in available timezones and adjusting timestamp conversion 2026-03-16 10:58:44 +05:30
Raj Nandan Sharma 932a05a9ee refactor: update timestamp handling to respect selected timezone 2026-03-16 10:55:24 +05:30
Raj Nandan Sharma 15c62fa40f add missing translations 2026-03-16 09:59:49 +05:30
github-actions[bot] a5afd38520 chore(release): bump version to 4.0.17 2026-03-15 13:27:35 +00:00
Raj Nandan Sharma 5138f7fb6e chore: update documentation for v4.0.17 release with new features and improvements 2026-03-15 18:32:01 +05:30
Raj Nandan Sharma 1bed0538db refactor: remove unused Breadcrumb import and enhance date/time format suggestions 2026-03-15 16:37:54 +05:30
Raj Nandan Sharma bd582eaad3 document: include "Deploy to Render" button in README and relevant documentation 2026-03-15 13:57:19 +05:30
Raj Nandan Sharma 5803ccadca refactor: remove dockerCommand and redefine ORIGIN env variable sourcing 2026-03-15 13:50:57 +05:30
Raj Nandan Sharma 4f16b06a0f chore: update dockerCommand in render.yaml and remove unused ORIGIN env variable 2026-03-15 13:44:09 +05:30
Raj Nandan Sharma fad23e2a01 Merge pull request #657 from rajnandan1/implement/issue-620
Implement/issue 620
2026-03-15 13:23:11 +05:30
Raj Nandan Sharma 4b84e47d89 create render.yaml for service and database configuration 2026-03-15 13:22:17 +05:30
Raj Nandan Sharma 9e020c10f4 document: include pre-built image instructions for deployment under /status base path 2026-03-15 12:56:50 +05:30
Raj Nandan Sharma 6fd47963ec refactor: improve formatting consistency in internationalization documentation 2026-03-15 12:39:16 +05:30
Raj Nandan Sharma df9b36b242 refactor: remove AllMaintenanceMonitorGrid component and update date formatting across various components to use dynamic date and time formats 2026-03-15 12:39:03 +05:30
Raj Nandan Sharma f9fe74ef94 refactor: streamline documentation layout and enhance styling 2026-03-14 10:08:14 +05:30
Raj Nandan Sharma 823ea6eeb7 Merge pull request #656 from rajnandan1/fix/group-timeout
Fix/group timeout
2026-03-13 19:21:00 +05:30
Raj Nandan Sharma 6c7606d3b0 fix group call through APIS 2026-03-13 17:24:26 +05:30
Raj Nandan Sharma d2e9437cfa fix group call through APIS 2026-03-13 17:23:59 +05:30
github-actions[bot] a549eb5d1b chore(release): bump version to 4.0.16 2026-03-13 07:59:48 +00:00
Raj Nandan Sharma 648f8180e7 feat: include changelog for version 4.0.16 with new features, improvements, and bug fixes 2026-03-13 11:29:18 +05:30
Raj Nandan Sharma 9afb8947ac feat: implement gRPC monitor functionality and update related documentation, implements #505 2026-03-13 10:29:05 +05:30
Raj Nandan Sharma 0c8338e2a5 Merge pull request #655 from rajnandan1/order-m-pages
feat: implement position management for page monitors and update rela…
2026-03-13 09:42:40 +05:30
Raj Nandan Sharma df94755c6b feat: implement position management for page monitors and update related functionality 2026-03-13 09:40:54 +05:30
Raj Nandan Sharma 204419fccb Merge pull request #654 from TobiX/fix-json-api
fix(api): Stringify existing objects before inserting into database
2026-03-13 09:02:08 +05:30
Tobias Gruetzmacher a973494bc8 fix(api): Stringify existing objects before inserting into database 2026-03-12 22:33:33 +01:00
Raj Nandan Sharma ba459e61ad Merge pull request #653 from rajnandan1/fix/csrf-origin
Fix/csrf origin
2026-03-12 23:06:22 +05:30
Raj Nandan Sharma f92fc8ff73 refactor: streamline CSRF handler method checks for request types 2026-03-12 23:03:14 +05:30
Raj Nandan Sharma 91cb4850ca validate request origin in CSRF handler to prevent null origins 2026-03-12 23:03:02 +05:30
Raj Nandan Sharma fb7939a4dc implement CSRF protection with origin validation in API routes, fixes #570 2026-03-12 22:54:39 +05:30
Raj Nandan Sharma 1f352591a4 chore: update README to include DeepWiki badge 2026-03-12 20:22:45 +05:30
Raj Nandan Sharma 0085621900 Merge pull request #648 from pan93412/locales/zh-tw
feat(locales): add zh-TW translation
2026-03-12 14:50:42 +05:30
Raj Nandan Sharma 0eb789d89a Merge pull request #652 from rajnandan1/zeabur
chore: integrate Zeabur deployment options in documentation and templ…
2026-03-12 14:49:35 +05:30
Raj Nandan Sharma 7f21b27bb8 chore: integrate Zeabur deployment options in documentation and templates 2026-03-12 14:39:45 +05:30
github-actions[bot] 7abd6788de chore(release): bump version to 4.0.15 2026-03-12 09:00:01 +00:00
Raj Nandan Sharma 681b116e09 bug fix 2026-03-12 14:03:19 +05:30
github-actions[bot] 04b1d0716b chore(release): bump version to 4.0.14 2026-03-12 05:28:52 +00:00
Raj Nandan Sharma 668f12a0eb Merge pull request #646 from rajnandan1/release/4.0.14.1
chore(locales): Update translations for various languages to include …
2026-03-12 09:49:18 +05:30
Raj Nandan Sharma 394149a3d6 chore: update changelog for v4.0.14 with scoring model changes, new features, bug fixes, and improvements 2026-03-12 09:47:45 +05:30
Raj Nandan Sharma 57c8ff28ab chore: update user-agent header to include dynamic version from version.js 2026-03-12 09:40:34 +05:30
Raj Nandan Sharma 5b29af9e74 chore: enhance webhook processing by including webhookURL in environment secrets and update alert incident URL formatting in template. fixes #647 2026-03-12 09:36:57 +05:30
Raj Nandan Sharma 9f5b90cb93 added svg logo 2026-03-12 09:06:10 +05:30
Yi-Jyun Pan e4f001acf7 feat(locales): add zh-TW translation 2026-03-12 11:18:53 +08:00
Raj Nandan Sharma 604dbccaea chore: refactor button and input components for better readability 2026-03-11 23:01:16 +05:30
Raj Nandan Sharma 6b7d0335b7 chore: implement search functionality and filter options for monitors 2026-03-11 23:00:04 +05:30
Raj Nandan Sharma b55dd29f02 chore: update notification duration display to include ongoing status 2026-03-11 22:32:00 +05:30
Raj Nandan Sharma dcd7918a08 chore(locales): Update translations for various languages to include new status terms
feat(dashboard): Refactor notification payload to include event start and end times

refactor(controllers): Modify group call scoring model to use normalized scores for status

docs(changelog): Document changes in group monitor scoring model in v4.0.14 release notes

feat(badges): Implement locale selection for status badges in management interface

refactor(i18n): Create server-side i18n helper for translating strings outside of Svelte components
2026-03-11 11:45:59 +05:30
Raj Nandan Sharma 38e3a538ef chore: enhance page navigation and update deployment documentation 2026-03-10 11:58:18 +05:30
Raj Nandan Sharma 19c2275a7b chore: remove unused dependencies and update documentation for showcase 2026-03-10 10:00:22 +05:30
175 changed files with 7112 additions and 2695 deletions
+123
View File
@@ -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
View File
@@ -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"
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
}
}
-195
View File
@@ -1,195 +0,0 @@
---
name: code-context
description: Persistent code architecture documentation via a `.codecontext/` folder.
user-invokable: false
metadata:
category: architecture
---
# Code Architecture Documentation Skill
Use this skill to **read architecture docs before work** and **document architecture after work** using the `.codecontext/` folder.
`.codecontext/` is a living architecture reference — it helps new developers onboard and coding agents pick up where previous sessions left off. It is **NOT** a session log, changelog, or task diary.
---
## What Belongs in `.codecontext/`
Only document **architecture-level knowledge** that would take significant effort to rediscover by reading code alone.
### Include
- **Code architecture** — how modules/components are structured, layered, and why
- **Code flow** — request lifecycle, data flow between layers, event/cron pipelines
- **Component relationships** — which modules depend on each other, call chains, shared state
- **Edge cases and gotchas** — non-obvious behaviors, race conditions, ordering constraints
- **Design decisions and rationale** — why a pattern was chosen over alternatives
- **Integration points** — how external services, databases, queues connect
- **Invariants and constraints** — rules that must hold (e.g., "timestamps are always UTC seconds", "all DB access goes through db singleton")
- **Error handling patterns** — how errors propagate, retry logic, fallback behavior
- **Key file map** — which files own which responsibilities (only when non-obvious)
### Exclude
- Session logs, changelogs, or diary-style entries
- What files were changed in a specific task
- Raw terminal output or build logs
- Code snippets (reference file paths + line ranges instead)
- Obvious facts that can be inferred from reading one file
- Task status, TODO lists, or progress tracking
- Anything already covered in README, AGENTS.md, or inline comments
---
## Trigger Conditions
Run this skill at the **start and end** of any coding task that touches architecture:
- Feature implementations spanning multiple files/modules
- Refactors that change module boundaries or data flow
- Bug fixes that reveal non-obvious system behavior
- New integrations or service connections
- Discovery of undocumented edge cases or invariants
**Skip** for trivial changes (typo fixes, single-line edits, style-only changes).
---
## Phase A — Read Architecture Docs (Before Acting)
### A1) Discover docs
```bash
ls .codecontext/
```
If `.codecontext/` does not exist, continue the task and create it in Phase B.
### A2) Find relevant docs
```bash
grep -ril "<domain keyword>" .codecontext/
```
Use keywords from the feature area you are working on (e.g., "alerting", "auth", "monitors", "cron").
### A3) Read and apply
Read only relevant files. Extract:
- Architecture constraints that affect your implementation
- Code flow you need to hook into or extend
- Edge cases to preserve or handle
- Integration points to respect
If existing docs conflict with current code, trust the code — update docs in Phase B.
---
## Phase B — Document Architecture (Before Ending)
Only write/update docs if the task revealed architecture knowledge worth preserving.
### B1) Decide what to document
Ask: _"Would a new developer or future agent need to re-discover this to work in this area?"_
If yes, proceed. If no, skip Phase B entirely.
Then apply this filter to **every sentence** before writing:
> "Does this sentence describe how the code is structured, a design decision, or a constraint that would change how someone writes future code in this area?"
If no → cut it. This is the line between architecture documentation and a session diary.
### B2) Write architecture documentation
Structure each doc as a **reference document**, not a session diary.
Template (use only the sections that apply):
```markdown
# <Domain/Feature Area>
## Overview
Brief description of what this area does and its role in the system.
## Architecture
How the components are structured, key abstractions, layers.
## Code Flow
Step-by-step flow for the primary operations (e.g., "How a monitor check executes").
## Key Files
| File | Responsibility |
| -------------------- | -------------- |
| `src/lib/server/...` | Does X |
## Edge Cases and Gotchas
- Non-obvious behavior 1
- Constraint that must be preserved
## Design Decisions
- Why X was chosen over Y (if non-obvious)
```
Not all sections are required — include only what is relevant. Keep each doc under **300 lines**.
### B3) Pick target file
```bash
ls .codecontext/
grep -ril "<topic keyword>" .codecontext/
```
| Condition | Action |
| ------------------------------- | -------------------------------- |
| Existing doc covers this domain | Update/rewrite relevant sections |
| Different domain | Create new file |
| No match | Create new file |
When updating, **replace outdated sections** rather than appending session entries. The doc should always read as a clean, current architecture reference.
### B4) Persist
```bash
mkdir -p .codecontext
```
Create or overwrite the file so it reads as a standalone reference:
```bash
cat > .codecontext/<domain>.md
```
---
## Naming Rules
- Name by domain/feature area: `alerting.md`, `auth.md`, `monitor-execution.md`, `incident-lifecycle.md`
- Use kebab-case for multi-word topics
- Never use generic names: `notes.md`, `misc.md`, `context.md`, `session-1.md`
- One file per bounded domain — split if a file exceeds ~300 lines
---
## Fast Checklist
Before coding:
- [ ] Checked `.codecontext/` for relevant architecture docs
- [ ] Applied constraints and patterns from existing docs
Before finishing:
- [ ] Every sentence passed the B1 architecture filter
- [ ] Documented any new architecture knowledge discovered
- [ ] Updated outdated docs if current code contradicts them
- [ ] Doc reads as a clean architecture reference, not a session log
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/ss-shadcn-svelte
-46
View File
@@ -1,46 +0,0 @@
# Database & Migrations
## Overview
Kener uses **Knex.js** as a database abstraction layer supporting three engines: **SQLite** (default, via `better-sqlite3`), **PostgreSQL** (`pg`), and **MySQL** (`mysql2`). The engine is selected at runtime from the `DATABASE_URL` environment variable prefix (`sqlite://`, `postgresql://`, `mysql://`).
## Architecture
### Connection & Config
| File | Responsibility |
| --------------------------------- | ---------------------------------------------------------- |
| `knexfile.ts` | Parses `DATABASE_URL`, selects client, exports Knex config |
| `src/lib/server/db/db.ts` | Singleton Knex instance — all app code imports from here |
| `src/lib/server/db/dbimpl.js` | High-level DB methods (wraps repositories) |
| `src/lib/server/db/repositories/` | Per-domain query classes extending `BaseRepository` |
### Repository Pattern
Each repository lives in `src/lib/server/db/repositories/<domain>.ts`, extends `BaseRepository` (which receives the Knex instance), and exposes typed async methods. Queries use Knex query builder — never raw SQL (except index creation wrapped in try/catch).
### Migrations
Migrations live in `migrations/` as TypeScript files with naming convention `YYYYMMDDHHMMSS_<description>.ts`.
Key patterns observed across all existing migrations:
- **Idempotency guards**: `knex.schema.hasTable` / `knex.schema.hasColumn` before `createTable` / `alterTable`.
- **Column types**: Knex abstractions only (`.string()`, `.integer()`, `.text()`, `.float()`). No raw DDL.
- **Defaults**: `.defaultTo()` + `.notNullable()` for YES/NO string flags (e.g., `is_hidden`, `is_owner`).
- **Data seeding in migrations**: Standard Knex query builder (`.orderBy().first()`, `.update()`) — works on all three engines.
- **Index creation**: Wrapped in `try/catch` because `CREATE INDEX IF NOT EXISTS` isn't portable.
- **PostgreSQL insert returning**: Some repos branch on `GetDbType() === "postgresql"` to use `.returning("*")`, with fallback to re-read by inserted ID for SQLite/MySQL.
## Edge Cases and Gotchas
- SQLite requires `useNullAsDefault: true` in Knex config.
- PostgreSQL `INSERT ... RETURNING *` is not supported by SQLite/MySQL — branch on `GetDbType()`.
- `knex.fn.now()` is the portable way to set timestamps; never use `NOW()` or `datetime('now')`.
- String-based YES/NO flags (not booleans) are the project convention for flag columns.
## Design Decisions
- **YES/NO strings over booleans**: Consistent with existing `is_hidden`, `include_degraded_in_downtime`, etc. Avoids SQLite boolean quirks.
- **hasColumn guard in migrations**: Allows re-running migrations safely without failure on already-applied columns.
- **Owner flag (`is_owner`)**: Set during migration on the first user by `id ASC`. Only one user should be owner; enforced at application level, not DB constraint.
+21 -10
View File
@@ -24,18 +24,29 @@ src/routes/(api)/api/
- **Repository**: `src/lib/server/db/repositories/*.ts` - Database operations
- **DbImpl**: `src/lib/server/db/dbimpl.ts` - Bindings for repository methods
### Current Locals (set by middleware in `hooks.server.ts`)
```typescript
interface Locals {
user?: SessionUser; // Auth session
monitor?: MonitorRecordTyped; // /api/monitors/:monitor_tag/*
incident?: IncidentRecord; // /api/incidents/:incident_id/*
maintenance?: MaintenanceRecord; // /api/maintenances/:maintenance_id/*
page?: PageRecord; // /api/pages/:page_path/*
}
```
## Naming Conventions
### Use snake_case for API payloads
```typescript
// Correct
// Correct
interface CreateMonitorRequest {
monitor_tag: string;
start_date_time: number;
duration_seconds: number;
}
// Wrong
// Wrong
interface CreateMonitorRequest {
monitorTag: string;
startDateTime: number;
@@ -251,7 +262,7 @@ export const DELETE: RequestHandler = async ({ locals }) => {
// Delete related records first (cascade)
await db.deleteResourceRelatedRecords(resource.id);
// Delete the resource itself
await db.deleteResource(resource.id);
@@ -275,8 +286,8 @@ const normalizedTs = GetMinuteStartTimestampUTC(body.start_date_time);
const now = GetNowTimestampUTC();
// For optional timestamp with fallback
const timestamp = body.timestamp !== undefined
? GetMinuteStartTimestampUTC(body.timestamp)
const timestamp = body.timestamp !== undefined
? GetMinuteStartTimestampUTC(body.timestamp)
: GetMinuteStartNowTimestampUTC();
```
@@ -300,8 +311,8 @@ if (typeof body.count !== "number" || isNaN(body.count) || body.count <= 0) {
```typescript
const VALID_STATUSES = ["ACTIVE", "INACTIVE"];
if (body.status && !VALID_STATUSES.includes(body.status)) {
return json({
error: { code: "BAD_REQUEST", message: `status must be one of: ${VALID_STATUSES.join(", ")}` }
return json({
error: { code: "BAD_REQUEST", message: `status must be one of: ${VALID_STATUSES.join(", ")}` }
}, { status: 400 });
}
```
@@ -311,8 +322,8 @@ if (body.status && !VALID_STATUSES.includes(body.status)) {
if (body.monitor_tag) {
const monitor = await db.getMonitorByTag(body.monitor_tag);
if (!monitor) {
return json({
error: { code: "BAD_REQUEST", message: `Monitor with tag '${body.monitor_tag}' not found` }
return json({
error: { code: "BAD_REQUEST", message: `Monitor with tag '${body.monitor_tag}' not found` }
}, { status: 400 });
}
}
@@ -324,7 +335,7 @@ if (body.items !== undefined) {
if (!Array.isArray(body.items)) {
return json({ error: { code: "BAD_REQUEST", message: "items must be an array" } }, { status: 400 });
}
for (const item of body.items) {
if (!item.tag || typeof item.tag !== "string") {
return json({ error: { code: "BAD_REQUEST", message: "Each item must have a valid tag" } }, { status: 400 });
+65 -61
View File
@@ -2,25 +2,37 @@
## Project Overview
Kener is an open-source status page application built with **SvelteKit 2.x** (**Svelte 5**) and **Node.js**, and is migrating to a **TypeScript-first** codebase. It provides real-time monitoring, uptime tracking, incident management, and customizable dashboards.
Kener is an open-source status page application built with **SvelteKit 2.x** (**Svelte 5**) and **Node.js/Express**. It is a **TypeScript-first** codebase providing real-time monitoring, uptime tracking, incident management, and customizable dashboards.
## Architecture
### Entry Points
- **`main.js`** - Production server entry: Express + SvelteKit handler + cron scheduler
- **`src/lib/server/startup.js`** - Cron job scheduler for monitors (runs every minute)
### Dual Process Model
In development, `npm run dev` runs two parallel processes:
1. **SvelteKit dev server** (`vite dev`) - serves the frontend with HMR
2. **Cron scheduler** (`vite-node src/lib/server/startup.ts`) - runs monitor checks, maintenance scheduling, daily cleanup
In production, **`scripts/main.ts`** is the single entry point: Express server + SvelteKit handler + migrations + seeds + scheduler startup. Built output runs via `node build/main.js`.
### Route Groups (SvelteKit)
- **`(kener)/`** - Public status page routes
- **`(manage)/`** - Admin dashboard (requires authentication)
- **`(embed)/`** - Embeddable widgets
- **`(docs)/`** - Documentation pages
- **`(api)/`** - SvelteKit API routes
- **`(account)/`** - Account/auth pages
- **`(ext)/`** - External integrations
- **`(assets)/`** - Asset serving
### Core Server Components
- **`src/lib/server/controllers/controller.js`** - Main business logic (~1700 lines), handles monitors, incidents, auth, email
- **`src/lib/server/db/dbimpl.js`** - Database abstraction layer using Knex.js
- **`src/lib/server/services/`** - Monitor type implementations: API, Ping, TCP, DNS, SSL, SQL, Heartbeat, GameDig, Group
- **`src/lib/server/cron-minute.js`** - Per-monitor cron execution logic
- **`src/lib/server/controllers/`** - Domain-split controllers (18 TypeScript files): `apiController.ts`, `incidentController.ts`, `monitorsController.ts`, `maintenanceController.ts`, `pagesController.ts`, `userController.ts`, `dashboardController.ts`, `emailController.ts`, `siteDataController.ts`, `validators.ts`, etc.
- **`src/lib/server/db/dbimpl.ts`** - Database abstraction layer using Knex.js with repository composition pattern
- **`src/lib/server/db/repositories/`** - Domain-driven repositories: `monitors.ts`, `incidents.ts`, `maintenances.ts`, `pages.ts`, `users.ts`, `alerts.ts`, `monitoring.ts`, `images.ts`, `subscriptionSystem.ts`, `emailTemplateConfig.ts`, `monitorAlertConfig.ts`, `site-data.ts`
- **`src/lib/server/services/`** - Monitor type implementations (all TypeScript): `apiCall.ts`, `pingCall.ts`, `tcpCall.ts`, `dnsCall.ts`, `sslCall.ts`, `sqlCall.ts`, `heartbeatCall.ts`, `gamedigCall.ts`, `groupCall.ts`, `grpcCall.ts`, `noneCall.ts`
- **`src/lib/server/schedulers/`** - Scheduling via `croner`: `appScheduler.ts`, `monitorSchedulers.ts`, `maintenanceScheduler.ts`, `dailyCleanup.ts`, `shutdown.ts`
- **`src/lib/server/queues/`** - Job queues via **BullMQ** + **Redis**: `monitorExecuteQueue.ts`, `monitorResponseQueue.ts`, `alertingQueue.ts`, `emailQueue.ts`, `subscriberQueue.ts`
- **`src/lib/server/api-server/`** - Express-side API handlers with file-based routing (directory/method pattern: e.g., `monitor-bar/get.ts`)
- **`src/lib/server/cron-minute.ts`** - Per-monitor cron execution logic
### Database
- Supports SQLite (default), PostgreSQL, MySQL via **Knex.js**
@@ -28,83 +40,97 @@ Kener is an open-source status page application built with **SvelteKit 2.x** (**
- Migrations in `/migrations/`, seeds in `/seeds/`
- Run migrations: `npm run migrate` or auto-runs on `npm start`
### Build System
`npm run build` is a two-step process:
1. `scripts/build-sveltekit.js` - Vite build of SvelteKit app (optionally with `--with-docs`)
2. `scripts/build-server.js` - esbuild bundles `scripts/main.ts` into `build/main.js`
## Development Commands
```bash
npm run dev # Start dev server with hot reload + cron scheduler
npm run build # Production build
npm run preview # Preview production build
npm run check # Typecheck + Svelte checks (uses tsconfig)
npm run dev # Start dev server (SvelteKit + cron scheduler in parallel)
npm run build # Production build (SvelteKit then esbuild server bundle)
npm run start # Run production build (node build/main.js)
npm run check # Svelte + TypeScript type checking
npm run prettify # Format all files with Prettier
npm run migrate # Run database migrations via Knex
npm run seed # Run database seeds
```
## Key Patterns
### Svelte 5 + TypeScript conventions
- Prefer **TypeScript** for new/modified code (`.ts`, and `.svelte` with `lang="ts"`).
- Prefer **Svelte 5 runes** for component state/effects in new code (e.g. `$state`, `$derived`, `$effect`).
- Prefer Svelte 5 props via `$props()` in new components. Keep existing `export let` props where already used to avoid churn.
- For SvelteKit route typing, prefer generated `$types` (e.g. `import type { PageServerLoad } from './$types'`).
- Avoid packages that hard-require Svelte 4 (they can break or force `--legacy-peer-deps`).
- Use **TypeScript** for all code (`.ts`, and `.svelte` with `lang="ts"`).
- Use **Svelte 5 runes** (`$state`, `$derived`, `$effect`, `$props()`) in components.
- For SvelteKit route typing, use generated `$types` (e.g. `import type { PageServerLoad } from './$types'`).
- Avoid packages that hard-require Svelte 4.
### Monitor Types
Defined in `src/lib/server/services/service.js`. Each type has its own implementation file:
```javascript
// Supported: API, PING, TCP, DNS, GROUP, SSL, SQL, HEARTBEAT, GAMEDIG
Defined in `src/lib/server/services/service.ts`. Each type has its own implementation file:
```typescript
// Supported: API, PING, TCP, DNS, GROUP, SSL, SQL, HEARTBEAT, GAMEDIG, GRPC, NONE
```
### Status Constants
Use constants from `src/lib/server/constants`:
```javascript
import { UP, DOWN, DEGRADED, MAINTENANCE, NO_DATA } from "./constants";
Use constants from `src/lib/global-constants.ts`:
```typescript
// In Svelte/client code:
import { UP, DOWN, DEGRADED, MAINTENANCE, NO_DATA } from "$lib/global-constants";
// In server code (use relative path):
import { UP, DOWN, DEGRADED, MAINTENANCE, NO_DATA } from "./global-constants";
```
### API Authentication
APIs use Bearer token auth verified via `VerifyAPIKey()`:
```javascript
import { VerifyAPIKey } from "$lib/server/controllers/controller.js";
```typescript
import { VerifyAPIKey } from "$lib/server/controllers/apiController";
```
### Database Queries
Always use the db singleton, never instantiate Knex directly:
```javascript
```typescript
import db from "$lib/server/db/db";
const monitor = await db.getMonitorByTag(tag);
```
### Timestamps
All timestamps are **UTC seconds** (not milliseconds). Use helpers from `src/lib/server/tool.js`:
```javascript
import { GetMinuteStartNowTimestampUTC, GetNowTimestampUTC } from "./tool";
All timestamps are **UTC seconds** (not milliseconds). Use helpers from `src/lib/server/tool.ts`:
```typescript
import { GetMinuteStartTimestampUTC, GetNowTimestampUTC } from "$lib/server/tool";
```
### i18n
Locales are in `src/lib/locales/`. Add new translations by creating `{code}.json` and updating `locales.json`.
21 locale files in `src/lib/locales/` (en, de, fr, es, hi, ja, ko, zh-CN, zh-TW, pt-BR, ru, etc.). Add new translations by creating `{code}.json` and updating `locales.json`.
## UI Components
Uses **shadcn-svelte** components in `src/lib/components/ui/`. Import pattern:
```javascript
Uses **shadcn-svelte** components in `src/lib/components/ui/` (40+ components). Import pattern:
```typescript
import { Button } from "$lib/components/ui/button";
```
Styling: **TailwindCSS** with HSL CSS variables for theming (see `tailwind.config.js`).
Styling: **Tailwind CSS v4** with CSS-based configuration (no `tailwind.config.js`). Theme uses HSL CSS variables defined in `src/routes/layout.css`.
## Environment Variables
Required in `.env`:
- `KENER_SECRET_KEY` - JWT secret for auth
Required:
- `KENER_SECRET_KEY` - Secret key for auth
- `ORIGIN` - Site URL (e.g., `http://localhost:3000`)
- `DATABASE_URL` - Database connection string
- `REDIS_URL` - Redis connection string (required for BullMQ job queues)
Optional:
- `DATABASE_URL` - Database connection string (defaults to SQLite)
- `KENER_BASE_PATH` - Base path for reverse proxy
- `PORT` - Server port (default 3000)
- `RESEND_API_KEY` / `RESEND_SENDER_EMAIL` - Email notifications
## File Conventions
- Server-only code: `src/lib/server/`
- Shared utilities: `src/lib/` (except `server/`)
- Route data loading: `+page.server.ts` / `+layout.server.ts` (and client-side `+page.ts` / `+layout.ts` when needed)
- Client utilities: `src/lib/client/`
- Route data loading: `+page.server.ts` / `+layout.server.ts`
- API endpoints: `+server.ts` files returning `json()`
## Types & Interfaces
@@ -112,29 +138,7 @@ Optional:
Place types and interfaces in the appropriate folder based on where they are used:
- **`src/lib/types/`** - Shared types (safe to import from both server and client code). Use for domain models, DTOs, API response types, and anything needed on both sides.
- **`src/lib/server/types/`** - Server-only types. Use for DB models, internal service types, auth/session types, and anything that uses `$env/static/private` or Node-only APIs.
- **`src/lib/client/types/`** - Client-only types. Use for UI-specific types, component prop types, and anything that relies on browser/DOM APIs.
- **`src/lib/server/types/`** - Server-only types (`db.ts`, `auth.ts`, `monitor.ts`, `api-server.ts`). Use for DB models, internal service types, auth/session types.
- **`src/lib/client/types/`** - Client-only types (`ui.ts`). Use for UI-specific types, component prop types.
Always use `import type { ... }` when importing types to avoid accidental runtime imports.
# Other skills
Read files in .claude/skills for more instructions on specific tasks or file types.
## Code Architecture Documentation (MUST)
For every coding task that touches architecture (multi-file features, refactors, new integrations):
1. **Before edits**
- Read and apply `.claude/skills/code-context/SKILL.md`.
- Load relevant architecture docs from `.codecontext/` when present.
2. **Before finishing the response**
- If the task revealed new architecture knowledge (code flow, edge cases, component relationships, design decisions), write/update a `.codecontext/*.md` entry as a clean reference doc.
- Skip if the task was trivial (typo fixes, single-line edits).
3. **Final response contract**
- Include a short line: `Context loaded: ...`
- Include a short line: `Context updated: ...`
`.codecontext/` documents **code architecture only** — not session logs, changelogs, or task summaries.
+2 -1
View File
@@ -19,4 +19,5 @@ config/static/*
!config/static/.kener
**/*.yaml
**/*.yml
.github/
.github/
src/lib/components/ui
-20
View File
@@ -38,23 +38,3 @@ When the user asks to write or edit documentation, follow the skill file:
- `.claude/skills/documentation-writer/SKILL.md`
This is mandatory for docs-related tasks. Prioritize short, clear, action-oriented docs and avoid bloat.
## Code architecture docs skill - Important for all tasks
Always try to use the code-context skill at the start and end of coding sessions:
- `.claude/skills/code-context/SKILL.md`
## Code architecture enforcement (mandatory)
The code-context skill is not optional. Agents MUST do both:
1. **Before coding**: load relevant architecture docs from `.codecontext/`.
2. **Before final response**: if the task revealed new architecture knowledge (code flow, edge cases, component relationships), update or create a `.codecontext/*.md` entry. Skip if the task was trivial.
Required output evidence in the final response:
- `Context loaded:` list of `.codecontext` files read (or `none found`).
- `Context updated:` exact `.codecontext` file path written (or `skipped — no architecture changes`).
The `.codecontext/` folder documents **code architecture only** — not session logs, changelogs, or task summaries.
+123
View File
@@ -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
+4
View File
@@ -135,6 +135,7 @@ ARG KENER_BASE_PATH=
ENV NODE_ENV=production \
PORT=${PORT} \
KENER_BASE_PATH=${KENER_BASE_PATH} \
BODY_SIZE_LIMIT=3M \
TZ=UTC \
# Required so Node can import .ts migration/seed files at runtime
NODE_OPTIONS="--experimental-strip-types"
@@ -164,6 +165,9 @@ COPY --chown=node:node --from=builder /app/src/lib/server/db/seedMonitorData.ts
COPY --chown=node:node --from=builder /app/src/lib/server/db/seedPagesData.ts ./src/lib/server/db/seedPagesData.ts
COPY --chown=node:node --from=builder /app/src/lib/server/templates/general ./src/lib/server/templates/general
# Locale JSON files (read at runtime by server-side i18n)
COPY --chown=node:node --from=builder /app/src/lib/locales ./src/lib/locales
# Build output (SvelteKit client/server + esbuild main.js) — changes most often
COPY --chown=node:node --from=builder /app/build ./build
+9 -4
View File
@@ -20,6 +20,7 @@
<a href="https://github.com/rajnandan1/kener/actions/workflows/publish-images.yml"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/rajnandan1/kener/publish-images.yml" /></a>
<a href="https://github.com/rajnandan1/kener/commit/HEAD"><img src="https://img.shields.io/github/last-commit/rajnandan1/kener/main" alt="" /></a>
<a href="https://github.com/rajnandan1/kener/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/rajnandan1/kener.svg" /></a>
<a href="https://deepwiki.com/rajnandan1/kener"><img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
</p>
<p align="center">
@@ -46,6 +47,14 @@
| [🌍 Live Server](https://kener.ing) | [🎉 Quick Start](https://kener.ing/docs/v4/getting-started/quick-start) | [🗄 Documentation](https://kener.ing/docs/v4/getting-started/introduction) |
| ----------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------- |
<p align="center">
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/spSvic?referralCode=1Pn7vs&utm_medium=integration&utm_source=template&utm_campaign=generic)
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/1YRTMI?referralCode=rajnandan1)
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https%3A%2F%2Fgithub.com%2Frajnandan1%2Fkener)
</p>
## What is Kener?
**Kener** is a sleek and lightweight status page system built with **SvelteKit** and **NodeJS**. Its not here to replace heavyweights like Datadog or Atlassian but rather to offer a simple, modern, and hassle-free way to set up a great-looking status page with minimal effort.
@@ -170,10 +179,6 @@ For the full quick start (including local Docker builds and dev mode), see the d
- https://kener.ing/docs/v4/getting-started/quick-start
## One Click Deployment
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/spSvic?referralCode=1Pn7vs&utm_medium=integration&utm_source=template&utm_campaign=generic)
## Features
Kener combines public status page essentials with advanced admin workflows.
+3
View File
@@ -1,6 +1,9 @@
#!/bin/sh
set -e
# Default body size limit for SvelteKit adapter-node (512K default is too small for image uploads)
export BODY_SIZE_LIMIT="${BODY_SIZE_LIMIT:-3M}"
# Index documentation into Redis when docs are bundled in the image
if [ -f /app/scripts/index-docs.ts ]; then
echo "[kener] Indexing documentation into Redis..."
-27
View File
@@ -1,27 +0,0 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": ["src/app.html", "build/main.js", "scripts/**/*.{js,ts}", "migrations/**/*.{js,ts}", "seeds/**/*.{js,ts}"],
"project": ["src/**/*.{js,ts,svelte}", "scripts/**/*.{js,ts}", "migrations/**/*.{js,ts}", "seeds/**/*.{js,ts}"],
"ignore": ["src/lib/components/ui/**"],
"ignoreDependencies": [
"@babel/runtime",
"js-yaml",
"mysql2",
"node-cache",
"pg",
"pg-pool",
"randomstring",
"style-to-object",
"lucide-svelte",
"marked-gfm-heading-id"
],
"ignoreExportsUsedInFile": {
"interface": true,
"type": true
},
"ignoreBinaries": [],
"compilers": {
"css": ["postcss"],
"svelte": ["svelte"]
}
}
@@ -0,0 +1,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;
}
}
+405 -293
View File
File diff suppressed because it is too large Load Diff
+17 -5
View File
@@ -1,6 +1,6 @@
{
"name": "kener",
"version": "4.0.13",
"version": "4.0.21",
"type": "module",
"private": false,
"license": "MIT",
@@ -55,7 +55,7 @@
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.48.5",
"@sveltejs/kit": "^2.53.3",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
@@ -64,7 +64,7 @@
"@types/d3-shape": "^3.1.8",
"@types/dns2": "^2.0.10",
"@types/express": "^5.0.6",
"@types/fs-extra": "^11.0.4",
"@types/heic-convert": "^2.1.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/mustache": "^4.2.6",
"@types/node": "^25.0.3",
@@ -80,7 +80,7 @@
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.43.8",
"svelte": "^5.53.5",
"svelte-awesome-color-picker": "^4.1.0",
"svelte-check": "^4.3.4",
"svelte-sonner": "^1.0.7",
@@ -113,6 +113,8 @@
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.39.11",
"@formkit/auto-animate": "^0.9.0",
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"@humanspeak/svelte-purify": "^0.0.6",
"@number-flow/svelte": "^0.3.9",
"@scalar/express-api-reference": "^0.8.28",
@@ -136,9 +138,9 @@
"figlet": "^1.9.4",
"flexsearch": "^0.8.212",
"front-matter": "^4.0.2",
"fs-extra": "^11.3.2",
"gamedig": "^5.3.2",
"glob": "^13.0.6",
"heic-convert": "^2.1.0",
"highlight.js": "^11.11.1",
"ioredis": "^5.8.2",
"js-yaml": "^4.1.1",
@@ -170,5 +172,15 @@
"style-to-object": "^1.0.14",
"svelte-codemirror-editor": "^2.1.0",
"vite-plugin-package-version": "^1.1.0"
},
"overrides": {
"fast-xml-parser": "^5.5.6",
"rollup": "^4.59.0",
"undici": "^7.24.0",
"minimatch": "^10.2.3",
"devalue": "^5.6.4",
"dompurify": "^3.3.2",
"cookie": "^0.7.0",
"mailparser": "^3.9.3"
}
}
+35
View File
@@ -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
-247
View File
@@ -1,247 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { globSync } from "glob";
import yaml from "js-yaml";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, "..");
const localesDir = path.join(projectRoot, "src", "lib", "locales");
const WHITELISTED_DYNAMIC_KEYS = new Set([
"All Systems Operational",
"Degraded Performance",
"Partial Degraded Performance",
"Partial System Outage",
"Major System Outage",
"No Status Available",
]);
function fail(message) {
throw new Error(message);
}
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function parseArgs(argv) {
let reportPath;
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--report") {
const next = argv[i + 1];
if (!next || next.startsWith("--")) {
fail("Missing value for --report. Usage: --report <path>");
}
reportPath = next;
i += 1;
continue;
}
if (arg.startsWith("--report=")) {
reportPath = arg.slice("--report=".length);
if (!reportPath) {
fail("Missing value for --report. Usage: --report <path>");
}
continue;
}
fail(`Unknown argument: ${arg}`);
}
return { reportPath };
}
function detectReportPath(overridePath) {
if (overridePath) {
const resolved = path.resolve(projectRoot, overridePath);
if (!fs.existsSync(resolved)) {
fail(`Report file not found: ${resolved}`);
}
return resolved;
}
const jsonPath = path.join(projectRoot, "translation-report.json");
const yamlPath = path.join(projectRoot, "translation-report.yaml");
if (fs.existsSync(jsonPath)) return jsonPath;
if (fs.existsSync(yamlPath)) return yamlPath;
fail(
"Could not find translation report. Expected translation-report.json or translation-report.yaml in project root, or use --report <path>.",
);
}
function loadReport(reportPath) {
const ext = path.extname(reportPath).toLowerCase();
const raw = fs.readFileSync(reportPath, "utf8");
let parsed;
try {
if (ext === ".json") {
parsed = JSON.parse(raw);
} else if (ext === ".yaml" || ext === ".yml") {
parsed = yaml.load(raw);
} else {
fail(`Unsupported report file extension: ${ext}. Use .json, .yaml, or .yml.`);
}
} catch (error) {
fail(`Failed to parse report at ${reportPath}: ${error instanceof Error ? error.message : String(error)}`);
}
if (!isPlainObject(parsed)) {
fail("Invalid report format: expected a top-level object.");
}
if (!isPlainObject(parsed.locales)) {
fail("Invalid report format: expected report.locales to be an object.");
}
return parsed;
}
function getUnusedKeysForLocale(report, localeFileName) {
const localeReport = report.locales[localeFileName];
if (localeReport === undefined) return [];
if (!isPlainObject(localeReport)) {
fail(`Invalid report format for locales.${localeFileName}: expected an object.`);
}
const { unused } = localeReport;
if (unused === undefined) return [];
if (!Array.isArray(unused)) {
fail(`Invalid report format for locales.${localeFileName}.unused: expected an array.`);
}
const nonStrings = unused.filter((key) => typeof key !== "string");
if (nonStrings.length > 0) {
fail(`Invalid report format for locales.${localeFileName}.unused: all entries must be strings.`);
}
return unused;
}
function loadLocaleJson(localePath, localeFileName) {
const raw = fs.readFileSync(localePath, "utf8");
let parsed;
try {
parsed = JSON.parse(raw);
} catch (error) {
fail(`Invalid JSON in ${localeFileName}: ${error instanceof Error ? error.message : String(error)}`);
}
if (!isPlainObject(parsed)) {
fail(`Invalid locale file ${localeFileName}: expected a top-level object.`);
}
if (!isPlainObject(parsed.mappings)) {
fail(`Invalid locale file ${localeFileName}: expected \"mappings\" to be an object.`);
}
return parsed;
}
function sortObjectKeysAscending(input) {
const keys = Object.keys(input).sort((a, b) => a.localeCompare(b));
const result = {};
for (const key of keys) {
result[key] = input[key];
}
return result;
}
function replaceMappingsPreserveTopLevelOrder(localeData, sortedMappings) {
const next = {};
let sawMappings = false;
for (const key of Object.keys(localeData)) {
if (key === "mappings") {
next[key] = sortedMappings;
sawMappings = true;
} else {
next[key] = localeData[key];
}
}
if (!sawMappings) {
next.mappings = sortedMappings;
}
return next;
}
function cleanTranslations(report) {
if (!fs.existsSync(localesDir)) {
fail(`Locales directory not found: ${localesDir}`);
}
const localeFiles = globSync("*.json", {
cwd: localesDir,
nodir: true,
}).sort((a, b) => a.localeCompare(b));
if (localeFiles.length === 0) {
fail(`No locale files found in ${localesDir}`);
}
let totalRemoved = 0;
const perFile = [];
for (const localeFileName of localeFiles) {
const localePath = path.join(localesDir, localeFileName);
const localeData = loadLocaleJson(localePath, localeFileName);
const unusedKeys = getUnusedKeysForLocale(report, localeFileName);
const unusedSet = new Set(unusedKeys.filter((key) => !WHITELISTED_DYNAMIC_KEYS.has(key)));
const currentMappings = localeData.mappings;
const cleanedMappings = {};
let removedCount = 0;
for (const [key, value] of Object.entries(currentMappings)) {
if (unusedSet.has(key)) {
removedCount += 1;
} else {
cleanedMappings[key] = value;
}
}
const sortedMappings = sortObjectKeysAscending(cleanedMappings);
const nextLocaleData = replaceMappingsPreserveTopLevelOrder(localeData, sortedMappings);
fs.writeFileSync(localePath, `${JSON.stringify(nextLocaleData, null, 2)}\n`, "utf8");
totalRemoved += removedCount;
perFile.push({ file: localeFileName, removed: removedCount });
}
for (const item of perFile) {
console.log(`${item.file}: removed ${item.removed} key${item.removed === 1 ? "" : "s"}`);
}
console.log(`Total removed: ${totalRemoved}`);
}
function main() {
const { reportPath: reportArg } = parseArgs(process.argv.slice(2));
const reportPath = detectReportPath(reportArg);
const report = loadReport(reportPath);
console.log(`Using report: ${path.relative(projectRoot, reportPath)}`);
cleanTranslations(report);
}
try {
main();
} catch (error) {
console.error("Failed to clean translations.");
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
+82 -74
View File
@@ -1,6 +1,6 @@
import { handler } from "../build/handler.js";
import dotenv from "dotenv";
dotenv.config();
import express from "express";
import Startup from "../src/lib/server/startup.ts";
import shutdownSchedulers from "../src/lib/server/schedulers/shutdown.ts";
@@ -12,82 +12,90 @@ import knexOb from "../knexfile.js";
const PORT = process.env.PORT || 3000;
const base = process.env.KENER_BASE_PATH || "";
const app: any = express();
const db = knex(knexOb);
async function start() {
// Dynamic import so BODY_SIZE_LIMIT from .env is available
// before the handler reads it at module top-level
const { handler } = await import("../build/handler.js");
app.get(base + "/healthcheck", (req: any, res: any) => {
res.end("ok");
});
const app: any = express();
const db = knex(knexOb);
app.use(handler);
app.get(base + "/healthcheck", (req: any, res: any) => {
res.end("ok");
});
//migrations
async function runMigrations() {
try {
// Rename old .js migration entries to .ts in the knex_migrations table
// so Knex can find the renamed files on disk
const hasTable = await db.schema.hasTable("knex_migrations");
if (hasTable) {
const oldJsMigrations = await db("knex_migrations").where("name", "like", "%.js");
for (const row of oldJsMigrations) {
const newName = row.name.replace(/\.js$/, ".ts");
await db("knex_migrations").where("id", row.id).update({ name: newName });
console.log(`Renamed migration record: ${row.name} -> ${newName}`);
}
}
app.use(handler);
console.log("Running migrations...");
await db.migrate.latest(); // Runs migrations to the latest state
console.log("Migrations completed successfully!");
} catch (err) {
console.error("Error running migrations:", err);
}
//migrations
async function runMigrations() {
try {
// Rename old .js migration entries to .ts in the knex_migrations table
// so Knex can find the renamed files on disk
const hasTable = await db.schema.hasTable("knex_migrations");
if (hasTable) {
const oldJsMigrations = await db("knex_migrations").where("name", "like", "%.js");
for (const row of oldJsMigrations) {
const newName = row.name.replace(/\.js$/, ".ts");
await db("knex_migrations").where("id", row.id).update({ name: newName });
console.log(`Renamed migration record: ${row.name} -> ${newName}`);
}
}
console.log("Running migrations...");
await db.migrate.latest(); // Runs migrations to the latest state
console.log("Migrations completed successfully!");
} catch (err) {
console.error("Error running migrations:", err);
}
}
//seed
async function runSeed() {
try {
console.log("Running seed...");
await db.seed.run(); // Runs seed to the latest state
console.log("Seed completed successfully!");
} catch (err) {
console.error("Error running seed:", err);
}
}
app.listen(PORT, async () => {
await runMigrations();
await runSeed();
await db.destroy();
Startup();
console.log("Kener is running on port " + PORT + "!");
});
// Graceful shutdown handler
async function gracefulShutdown(signal: string) {
console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
try {
console.log("Shutting down schedulers...");
await shutdownSchedulers();
console.log("Schedulers shut down successfully.");
console.log("Shutting down queues...");
await shutdownQueues();
console.log("Queues shut down successfully.");
console.log("Closing database connection...");
await dbInstance.close();
console.log("Database connection closed successfully.");
console.log("Graceful shutdown completed.");
process.exit(0);
} catch (err) {
console.error("Error during graceful shutdown:", err);
process.exit(1);
}
}
// Handle termination signals
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
}
//seed
async function runSeed() {
try {
console.log("Running seed...");
await db.seed.run(); // Runs seed to the latest state
console.log("Seed completed successfully!");
} catch (err) {
console.error("Error running seed:", err);
}
}
app.listen(PORT, async () => {
await runMigrations();
await runSeed();
await db.destroy();
Startup();
console.log("Kener is running on port " + PORT + "!");
});
// Graceful shutdown handler
async function gracefulShutdown(signal: string) {
console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
try {
console.log("Shutting down schedulers...");
await shutdownSchedulers();
console.log("Schedulers shut down successfully.");
console.log("Shutting down queues...");
await shutdownQueues();
console.log("Queues shut down successfully.");
console.log("Closing database connection...");
await dbInstance.close();
console.log("Database connection closed successfully.");
console.log("Graceful shutdown completed.");
process.exit(0);
} catch (err) {
console.error("Error during graceful shutdown:", err);
process.exit(1);
}
}
// Handle termination signals
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
start();
+2
View File
@@ -30,6 +30,7 @@ export async function seed(knex: Knex): Promise<void> {
page_id: pageId,
monitor_tag: "earth",
monitor_settings_json: "",
position: 0,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
@@ -42,6 +43,7 @@ export async function seed(knex: Knex): Promise<void> {
page_id: pageId,
monitor_tag: "kener",
monitor_settings_json: "",
position: 1,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
+10
View File
@@ -0,0 +1,10 @@
{
"version": 1,
"skills": {
"ss-shadcn-svelte": {
"source": "rajnandan1/such-skills",
"sourceType": "github",
"computedHash": "0678d0cad0bce1d56731c9613e75f2d274810ad2a17ecea2d21434b6416c90b7"
}
}
}
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta property="og:locale" content="en_US" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
+35 -1
View File
@@ -1,4 +1,5 @@
import { json, type Handle } from "@sveltejs/kit";
import { sequence } from "@sveltejs/kit/hooks";
import { VerifyAPIKey } from "$lib/server/controllers/apiController";
import db from "$lib/server/db/db";
import type { UnauthorizedResponse, NotFoundResponse } from "$lib/types/api";
@@ -58,7 +59,38 @@ function extractPagePath(pathname: string): string | null {
return match ? decodeURIComponent(match[1]) : null;
}
export const handle: Handle = async ({ event, resolve }) => {
// Content types that indicate a form submission (mirrors SvelteKit's internal CSRF check scope)
const FORM_CONTENT_TYPES = ["application/x-www-form-urlencoded", "multipart/form-data", "text/plain"];
function isFormContentType(request: Request): boolean {
const type = request.headers.get("content-type")?.split(";", 1)[0].trim()?.toLowerCase() ?? "";
return FORM_CONTENT_TYPES.includes(type);
}
// Custom CSRF handler: validates Origin when present, allows requests when absent.
// When Origin is absent (e.g. Referrer-Policy: no-referrer), security relies on
// SameSite=Lax cookies which prevent cross-site POST from carrying auth cookies.
const csrfHandle: Handle = async ({ event, resolve }) => {
const { request } = event;
if (
isFormContentType(request) &&
(request.method === "POST" || request.method === "PUT" || request.method === "PATCH" || request.method === "DELETE")
) {
const requestOrigin = request.headers.get("origin");
if (requestOrigin && requestOrigin !== "null") {
const requestHost = new URL(requestOrigin).host;
const expectedHost = event.url.host;
if (requestHost !== expectedHost) {
return new Response(`Cross-site ${request.method} form submissions are forbidden`, { status: 403 });
}
}
}
return resolve(event);
};
const apiAuthHandle: Handle = async ({ event, resolve }) => {
const { pathname } = event.url;
// Check if this is an API route that requires authentication
@@ -160,3 +192,5 @@ export const handle: Handle = async ({ event, resolve }) => {
response.headers.delete("Link");
return response;
};
export const handle = sequence(csrfHandle, apiAuthHandle);
@@ -1,188 +0,0 @@
<script lang="ts">
import { Badge } from "$lib/components/ui/badge/index.js";
import Clock from "@lucide/svelte/icons/clock";
import CalendarClock from "@lucide/svelte/icons/calendar-clock";
import Timer from "@lucide/svelte/icons/timer";
import { t } from "$lib/stores/i18n";
import { formatDate, formatDuration } from "$lib/stores/datetime";
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
interface Maintenance {
id: number;
title: string;
description?: string | null;
start_date_time: number;
end_date_time: number;
monitor_tag?: string;
}
interface Props {
ongoingMaintenances?: Maintenance[];
upcomingMaintenances?: Maintenance[];
pastMaintenances?: Maintenance[];
class?: string;
}
let {
ongoingMaintenances = [],
upcomingMaintenances = [],
pastMaintenances = [],
class: className = ""
}: Props = $props();
// Deduplicate maintenances by id (can have duplicates due to multiple monitors)
function deduplicateMaintenances(maintenances: Maintenance[]): Maintenance[] {
const seen = new Set<number>();
return maintenances.filter((m) => {
if (seen.has(m.id)) return false;
seen.add(m.id);
return true;
});
}
// Deduplicated arrays
let uniqueOngoing = $derived(deduplicateMaintenances(ongoingMaintenances));
let uniqueUpcoming = $derived(deduplicateMaintenances(upcomingMaintenances));
let uniquePast = $derived(deduplicateMaintenances(pastMaintenances));
// Check if there's any maintenance data
let hasAnyData = $derived(uniqueOngoing.length > 0 || uniqueUpcoming.length > 0 || uniquePast.length > 0);
</script>
{#if hasAnyData}
<div class="bg-background rounded-3xl border {className}">
<div class=" flex items-center justify-between p-4">
<Badge variant="secondary" class="gap-1">{$t("Maintenances")}</Badge>
</div>
<div class="grid grid-cols-1 gap-0 lg:grid-cols-3">
<!-- Ongoing Maintenances -->
<div class="flex flex-col lg:border-r">
<div class="text-muted-foreground bg-secondary flex items-center justify-between gap-2 p-4 text-sm font-medium">
<div class="flex items-center gap-2">
<div class="bg-maintenance h-2 w-2 rounded-full"></div>
{$t("Ongoing")}
</div>
<div class="text-maintenance">
<span>{uniqueOngoing.length}</span>
</div>
</div>
<div class="scrollbar-hidden max-h-64 overflow-y-auto">
{#if uniqueOngoing.length === 0}
<p class="text-muted-foreground py-4 text-center text-xs">{$t("No ongoing maintenances")}</p>
{:else}
{#each uniqueOngoing as maintenance (maintenance.id)}
<a
href={clientResolver(resolve, `/maintenances/${maintenance.id}`)}
class="hover:bg-muted/50 block border-b p-3 transition-colors last:border-0"
>
<h4 class="line-clamp-2 text-sm leading-tight font-medium">{maintenance.title}</h4>
{#if maintenance.description}
<p class="text-muted-foreground mt-1 line-clamp-3 text-xs leading-relaxed">
{maintenance.description}
</p>
{/if}
<div class="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span class="flex items-center gap-1">
<Clock class="h-3 w-3" />
{$formatDate(maintenance.start_date_time, "MMM d, HH:mm")}
</span>
<span class="flex items-center gap-1">
<Timer class="h-3 w-3" />
{$formatDuration(maintenance.start_date_time, maintenance.end_date_time)}
</span>
</div>
</a>
{/each}
{/if}
</div>
</div>
<!-- Upcoming Maintenances -->
<div class="flex flex-col lg:border-r">
<div class="text-muted-foreground bg-secondary flex items-center justify-between gap-2 p-4 text-sm font-medium">
<div class="flex items-center gap-2">
<div class="bg-primary h-2 w-2 rounded-full"></div>
{$t("Upcoming")}
</div>
<div>
{uniqueUpcoming.length}
</div>
</div>
<div class="scrollbar-hidden max-h-64 overflow-y-auto">
{#if uniqueUpcoming.length === 0}
<p class="text-muted-foreground py-4 text-center text-xs">{$t("No upcoming maintenances")}</p>
{:else}
{#each uniqueUpcoming as maintenance (maintenance.id)}
<a
href={clientResolver(resolve, `/maintenances/${maintenance.id}`)}
class="hover:bg-muted/50 block border-b p-3 transition-colors last:border-0"
>
<h4 class="line-clamp-2 text-sm leading-tight font-medium">{maintenance.title}</h4>
{#if maintenance.description}
<p class="text-muted-foreground mt-1 line-clamp-3 text-xs leading-relaxed">
{maintenance.description}
</p>
{/if}
<div class="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span class="flex items-center gap-1">
<CalendarClock class="h-3 w-3" />
{$formatDate(maintenance.start_date_time, "MMM d, HH:mm")}
</span>
<span class="flex items-center gap-1">
<Timer class="h-3 w-3" />
{$formatDuration(maintenance.start_date_time, maintenance.end_date_time)}
</span>
</div>
</a>
{/each}
{/if}
</div>
</div>
<!-- Past Maintenances -->
<div class="flex flex-col">
<div class="text-muted-foreground bg-secondary flex items-center justify-between gap-2 p-4 text-sm font-medium">
<div class="flex items-center gap-2">
<div class="bg-muted-foreground h-2 w-2 rounded-full"></div>
{$t("Past")}
</div>
<div>
{uniquePast.length}
</div>
</div>
<div class="scrollbar-hidden max-h-64 overflow-y-auto">
{#if uniquePast.length === 0}
<p class="text-muted-foreground py-4 text-center text-xs">{$t("No past maintenances")}</p>
{:else}
{#each uniquePast as maintenance (maintenance.id)}
<a
href={clientResolver(resolve, `/maintenances/${maintenance.id}`)}
class="hover:bg-muted/50 block border-b p-3 transition-colors last:border-0"
>
<h4 class="line-clamp-2 text-sm leading-tight font-medium">{maintenance.title}</h4>
{#if maintenance.description}
<p class="text-muted-foreground mt-1 line-clamp-3 text-xs leading-relaxed">
{maintenance.description}
</p>
{/if}
<div class="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span class="flex items-center gap-1">
<Clock class="h-3 w-3" />
{$formatDate(maintenance.start_date_time, "MMM d, HH:mm")}
</span>
<span class="flex items-center gap-1">
<Timer class="h-3 w-3" />
{$formatDuration(maintenance.start_date_time, maintenance.end_date_time)}
</span>
</div>
</a>
{/each}
{/if}
</div>
</div>
</div>
</div>
{/if}
@@ -41,7 +41,7 @@
</Drawer.Trigger>
<Drawer.Content class="max-h-[80vh]">
<Drawer.Header>
<Drawer.Title>{$t("Included Monitors")}</Drawer.Title>
<Drawer.Title>{$t("Included Monitors (%count)", { count: String(tags.length) })}</Drawer.Title>
</Drawer.Header>
<div class="scrollbar-hidden flex flex-col overflow-y-auto px-4 pb-4">
{#if tags.length === 0}
+4 -4
View File
@@ -105,7 +105,7 @@
class="mt-2 flex w-full flex-col gap-2 text-xs font-medium sm:flex-row sm:items-center sm:justify-between"
>
<span class="max-w-full rounded-full border px-3 py-2 wrap-break-word">
{$formatDate(incident.start_date_time, "PPp")}
{$formatDate(incident.start_date_time, page.data.dateAndTimeFormat.datePlusTime)}
</span>
<span class="relative w-full text-center sm:flex-1">
<span
@@ -117,7 +117,7 @@
</span>
{#if incident.end_date_time}
<span class="max-w-full rounded-full border px-3 py-2 wrap-break-word">
{$formatDate(incident.end_date_time, "PPp")}
{$formatDate(incident.end_date_time, page.data.dateAndTimeFormat.datePlusTime)}
</span>
{:else}
<span class="max-w-full rounded-full border px-3 py-2 wrap-break-word">
@@ -129,7 +129,7 @@
<div class="my-2 grid grid-cols-1 gap-4 text-xs font-medium sm:grid-cols-3">
<div class="text-muted-foreground bg-secondary flex items-center justify-between rounded-full border p-2 px-4">
<span>{$t("Last Updated")}</span>
<span>{$formatDate(incident.updated_at, "PPp")}</span>
<span>{$formatDate(incident.updated_at, page.data.dateAndTimeFormat.datePlusTime)}</span>
</div>
<div class="text-muted-foreground bg-secondary flex items-center justify-between rounded-full border p-2 px-4">
<span>{$t("Status")}</span>
@@ -167,7 +167,7 @@
{$t(comment.state)}
</Badge>
<span class="text-muted-foreground text-xs">
{$formatDate(comment.commented_at, "PPp")}
{$formatDate(comment.commented_at, page.data.dateAndTimeFormat.datePlusTime)}
</span>
</div>
<div
+4 -1
View File
@@ -6,6 +6,7 @@
import { t } from "$lib/stores/i18n";
import { ParseLatency } from "$lib/clientTools";
import { formatDate } from "$lib/stores/datetime";
import { page } from "$app/state";
interface ChartPoint {
date: Date;
@@ -82,7 +83,9 @@
></div>
<div class="flex flex-1 flex-col items-start justify-between gap-1 leading-none">
<span class="text-muted-foreground text-xs"
>{item.payload?.date ? $formatDate(item.payload.date, "MMM d") : ""}</span
>{item.payload?.date
? $formatDate(item.payload.date, page.data.dateAndTimeFormat.dateOnly)
: ""}</span
>
<div class="flex items-center gap-2">
<span class="text-foreground font-mono font-medium tabular-nums">
+2 -2
View File
@@ -97,7 +97,7 @@
class="mt-2 flex w-full flex-col gap-2 text-xs font-medium sm:flex-row sm:items-center sm:justify-between"
>
<span class="max-w-full rounded-full border px-3 py-2 wrap-break-word">
{$formatDate(maintenance.start_date_time, "PPp")}
{$formatDate(maintenance.start_date_time, page.data.dateAndTimeFormat.datePlusTime)}
</span>
<span class="relative w-full text-center sm:flex-1">
<span
@@ -108,7 +108,7 @@
</span>
</span>
<span class="max-w-full rounded-full border px-3 py-2 wrap-break-word">
{$formatDate(maintenance.end_date_time, "PPp")}
{$formatDate(maintenance.end_date_time, page.data.dateAndTimeFormat.datePlusTime)}
</span>
</Item.Description>
</Item.Content>
+7 -2
View File
@@ -5,6 +5,8 @@
import TrendingUp from "@lucide/svelte/icons/trending-up";
import { t } from "$lib/stores/i18n";
import { formatDate } from "$lib/stores/datetime";
import { selectedTimezone } from "$lib/stores/timezone";
import { toZonedTime } from "date-fns-tz";
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
interface MinuteData {
@@ -74,7 +76,7 @@
const minutesByHour: Map<number, MinuteData[]> = new Map();
for (const minute of minutes) {
const date = new Date(minute.timestamp * 1000);
const date = toZonedTime(minute.timestamp * 1000, $selectedTimezone);
const hour = date.getHours();
if (!minutesByHour.has(hour)) {
@@ -319,7 +321,10 @@
style={tooltipStyle}
>
<span class="text-{hoveredMinute.data.status.toLowerCase()}">
{$t(hoveredMinute.data.status)} @ {$formatDate(hoveredMinute.data.timestamp, "HH:mm")}
{$t(hoveredMinute.data.status)} @ {$formatDate(
hoveredMinute.data.timestamp,
page.data.dateAndTimeFormat.timeOnly
)}
</span>
</div>
{/if}
+4 -5
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import * as Item from "$lib/components/ui/item/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
import * as Avatar from "$lib/components/ui/avatar/index.js";
import ICONS from "$lib/icons";
@@ -12,6 +11,7 @@
import { GetInitials } from "$lib/clientTools.js";
import GroupMonitorPopover from "./GroupMonitorPopover.svelte";
import { t } from "$lib/stores/i18n";
import { page } from "$app/state";
interface Props {
tag: string;
@@ -136,11 +136,11 @@
<StatusBarCalendar data={data.uptimeData} monitorTag={tag} barHeight={40} radius={8} />
<div class="flex min-w-0 justify-between gap-3">
<p class="text-muted-foreground min-w-0 truncate text-xs font-medium">
{$formatDate(new Date(data.fromTimeStamp * 1000), "MMM d, yyyy")}
{$formatDate(new Date(data.fromTimeStamp * 1000), page.data.dateAndTimeFormat.dateOnly)}
</p>
<p class="text-muted-foreground min-w-0 truncate text-right text-xs font-medium">
{$formatDate(new Date(data.toTimeStamp * 1000), "MMM d, yyyy")}
{$formatDate(new Date(data.toTimeStamp * 1000), page.data.dateAndTimeFormat.dateOnly)}
</p>
</div>
</div>
@@ -152,8 +152,7 @@
days={days as number}
endOfDayTodayAtTz={endOfDayTodayAtTz as number}
>
{groupChildTags.length}
{$t("Included Monitors")}
{$t("Included Monitors (%count)", { count: String(groupChildTags.length) })}
</GroupMonitorPopover>
</div>
{/if}
+8 -11
View File
@@ -3,24 +3,20 @@
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
import { resolve } from "$app/paths";
import TrendingUp from "@lucide/svelte/icons/trending-up";
import Clock from "@lucide/svelte/icons/clock";
import Activity from "@lucide/svelte/icons/activity";
import { Button } from "$lib/components/ui/button/index.js";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
import LoaderBoxes from "$lib/components/loaderbox.svelte";
import constants from "$lib/global-constants.js";
import * as Tabs from "$lib/components/ui/tabs/index.js";
import { page } from "$app/state";
import { AreaChart, Area, LinearGradient } from "layerchart";
import { curveCatmullRom } from "d3-shape";
import { scaleOrdinal, scaleSequential, scaleTime } from "d3-scale";
import { scaleTime } from "d3-scale";
import IncidentItem from "$lib/components/IncidentItem.svelte";
import MaintenanceItem from "$lib/components/MaintenanceItem.svelte";
import MinuteGrid from "$lib/components/MinuteGrid.svelte";
import clientResolver from "$lib/client/resolver.js";
import { ParseLatency } from "$lib/clientTools";
import * as Chart from "$lib/components/ui/chart/index.js";
import type { IncidentForMonitorListWithComments, MaintenanceEventsMonitorList } from "$lib/server/types/db";
@@ -213,9 +209,8 @@
<Dialog.Content class="max-h-[90vh] overflow-y-auto rounded-3xl p-4 sm:max-w-[46.5rem] sm:p-6">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2 text-base sm:text-lg">
<Activity class="h-4 w-4 shrink-0 sm:h-5 sm:w-5" />
<span class="truncate">
{selectedDay ? $formatDate(new Date(selectedDay.timestamp * 1000), "EEEE, MMMM do, yyyy") : ""}
{selectedDay ? $formatDate(new Date(selectedDay.timestamp * 1000), page.data.dateAndTimeFormat.dateOnly) : ""}
</span>
</Dialog.Title>
<Dialog.Description class="text-xs sm:text-sm"
@@ -316,7 +311,7 @@
line: { class: "stroke-1" }
},
xAxis: {
format: (d: Date) => $formatDate(d, "HH:mm")
format: (d: Date) => $formatDate(d, page.data.dateAndTimeFormat.timeOnly)
}
}}
>
@@ -342,11 +337,13 @@
></div>
<div class="flex flex-1 flex-col items-start justify-between gap-1 leading-none">
<span class="text-muted-foreground text-xs">
{item.payload?.date ? $formatDate(item.payload.date, "HH:mm") : ""}
{item.payload?.date
? $formatDate(item.payload.date, page.data.dateAndTimeFormat.timeOnly)
: ""}
</span>
<div class="flex items-center gap-2">
<span class="text-foreground font-mono font-medium tabular-nums">
{Math.round(Number(value))} ms
{ParseLatency(Math.round(Number(value)))}
</span>
</div>
</div>
+4 -4
View File
@@ -2,7 +2,6 @@
import { onMount, untrack } from "svelte";
import * as Card from "$lib/components/ui/card/index.js";
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import StatusBarCalendar from "$lib/components/StatusBarCalendar.svelte";
import LatencyTrendChart from "$lib/components/LatencyTrendChart.svelte";
@@ -18,6 +17,7 @@
import * as Popover from "$lib/components/ui/popover/index.js";
import * as ToggleGroup from "$lib/components/ui/toggle-group/index.js";
import GroupMonitorPopover from "$lib/components/GroupMonitorPopover.svelte";
import { page } from "$app/state";
interface Props {
monitorTag: string;
@@ -205,12 +205,12 @@
<div class="flex justify-between">
<p class="text-muted-foreground text-xs font-medium">
{#if displayData.length > 0}
{$formatDate(displayData[0].ts, "d MMM yyyy")}
{$formatDate(displayData[0].ts, page.data.dateAndTimeFormat.dateOnly)}
{/if}
</p>
<p class="text-muted-foreground text-xs font-medium">
{#if displayData.length > 0}
{$formatDate(displayData[displayData.length - 1].ts, "d MMM yyyy")}
{$formatDate(displayData[displayData.length - 1].ts, page.data.dateAndTimeFormat.dateOnly)}
{/if}
</p>
</div>
@@ -219,7 +219,7 @@
{#if groupTags.length > 0}
<div class="flex justify-center">
<GroupMonitorPopover tags={groupTags} days={selectedDays} {endOfDayTodayAtTz}>
{$t("Included Monitors")} ({groupTags.length})
{$t("Included Monitors (%count)", { count: String(groupTags.length) })}
<ArrowUp class="size-3" />
</GroupMonitorPopover>
</div>
@@ -6,7 +6,7 @@
import { Spinner } from "$lib/components/ui/spinner/index.js";
import ICONS from "$lib/icons";
import clientResolver from "$lib/client/resolver.js";
import { formatDate } from "$lib/stores/datetime";
import { formatDate, formatDuration } from "$lib/stores/datetime";
import { t } from "$lib/stores/i18n";
import type { NotificationEvent } from "$lib/server/controllers/dashboardController.js";
import Calendar from "@lucide/svelte/icons/calendar-1";
@@ -106,17 +106,17 @@
class="hover:bg-muted/60 block border-b px-4 py-3 last:border-b-0"
>
<div class="my-0.5 flex items-center justify-between gap-2 text-xs">
<span class="text-muted-foreground text-[11px] uppercase">{item.eventType}</span>
<span class="text-{item.eventStatus.toLowerCase()}">{item.eventStatus}</span>
<span class="text-muted-foreground text-[11px] uppercase">{$t(item.eventType)}</span>
<span class="text-{item.eventStatus.toLowerCase()}">{$t(item.eventStatus)}</span>
</div>
<div class="flex items-start justify-between gap-2">
<p class="line-clamp-2 text-sm">{item.eventTitle}</p>
</div>
<div class="text-muted-foreground mt-1 flex flex-wrap items-center gap-2 text-xs">
<span>{$formatDate(item.eventDate, "PPp")}</span>
<span>{$formatDate(item.eventDate, page.data.dateAndTimeFormat.datePlusTime)}</span>
<span></span>
<span>{item.eventDuration}</span>
<span>{$formatDuration(item.eventStartDateTime, item.eventEndDateTime, $t("Ongoing"))}</span>
</div>
</a>
{/each}
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
import { onMount } from "svelte";
import type { PageNavItem } from "$lib/server/controllers/dashboardController.js";
import { page } from "$app/state";
import * as Item from "$lib/components/ui/item/index.js";
import ChevronRight from "@lucide/svelte/icons/chevron-right";
let currentPath = $derived(page.params.page_path);
let pages = $state<PageNavItem[]>([]);
let pagesLoading = $state(false);
const defaultHomePage = $derived(pages.find((p) => p.page_path == ""));
const currentPage = $derived(pages.find((p) => p.page_path === currentPath) || defaultHomePage);
async function fetchPages() {
pagesLoading = true;
try {
const response = await fetch(clientResolver(resolve, "/dashboard-apis/pages"));
if (response.ok) {
pages = await response.json();
console.log(">>>>>>---- PageList:25 ", pages);
}
} catch {
// silently fail, pages dropdown will just not show
} finally {
pagesLoading = false;
}
}
onMount(() => {
fetchPages();
});
</script>
<div class="flex shrink-0 items-center gap-2">
{#if pagesLoading}
<Button
variant="outline"
size="sm"
class="bg-background/80 dark:bg-background/70 border-foreground/10 flex items-center justify-center rounded-full border text-xs shadow-none backdrop-blur-md"
disabled
>
<Spinner class="h-4 w-4" />
</Button>
{:else if pages.length > 0}
<!-- loop through pages -->
<div class="flex w-full flex-col gap-2">
{#each pages as page}
<Item.Root variant="outline" class="rounded-3xl">
{#snippet child({ props })}
<a href={clientResolver(resolve, `/${page.page_path}`)} {...props}>
{#if page.page_logo}
<Item.Media variant="image">
<img
src={page.page_logo}
alt={page.page_title}
width="32"
height="32"
class="size-8 rounded object-cover"
/>
</Item.Media>
{/if}
<Item.Content>
<Item.Title class="line-clamp-1">
{page.page_title}
</Item.Title>
<Item.Description>{page.page_header}</Item.Description>
</Item.Content>
<Item.Actions>
<ChevronRight class="size-4" />
</Item.Actions>
</a>
{/snippet}
</Item.Root>
{/each}
</div>
{/if}
</div>
+1 -1
View File
@@ -375,7 +375,7 @@
>
<span class={getStatusColor(hoveredBar.data)}>{$t(GetStatusSummary(hoveredBar.data))}</span>
<span class="text-muted-foreground">@</span>
{$formatDate(hoveredBar.data.ts, "d MMM yyyy")}
{$formatDate(hoveredBar.data.ts, page.data.dateAndTimeFormat.dateOnly)}
{#if hoveredBar.data.avgLatency > 0}
<span class="text-muted-foreground ml-1">|</span>
<span class="ml-1">{ParseLatency(hoveredBar.data.avgLatency)}</span>
+2
View File
@@ -78,6 +78,7 @@
</script>
<div class="theme-plus-bar scrollbar-hidden sticky top-18 z-20 flex w-full items-center gap-2 rounded py-2">
<!-- Show the switcher if not forced exclusivity and switcher is enabled -->
{#if !!!page.data.globalPageVisibilitySettings.forceExclusivity && page.data.globalPageVisibilitySettings.showSwitcher}
<PageSelector />
{/if}
@@ -169,6 +170,7 @@
{/if}
</div>
</div>
{#if !!page.data.announcement && !!page.data.announcement.title && !!page.data.announcement.message}
<SiteBanner announcement={page.data.announcement} />
{/if}
+31 -25
View File
@@ -22,36 +22,39 @@
"Day": "Den",
"Day Uptime": "Denní dostupnost",
"Days": "Dní",
"DEGRADED": "ZHORŠENÝ",
"Degraded Performance": "Zhoršený výkon",
"Didn't receive the code? Resend": "Nepřišel vám kód? Odeslat znovu",
"DOWN": "VÝPADEK",
"Degraded": "Omezený",
"DEGRADED": "OMEZENÝ",
"Degraded Performance": "Snížený výkon",
"Didn't receive the code? Resend": "Nepřišel kód? Odeslat znovu",
"Down": "Nedostupné",
"DOWN": "NEDOSTUPNÉ",
"Duration": "Trvání",
"Edit Monitor": "Upravit monitor",
"Email address": "E-mailová adresa",
"Embed Monitor": "Vložit monitor",
"Embed this monitor in your website or app": "Vložte tento monitor na svůj web nebo do aplikace",
"Embed this monitor in your website or app": "Vložte tento monitor na web nebo do aplikace",
"End Time": "Konec",
"Enter the verification code sent to your email.": "Zadejte ověřovací kód zaslaný na váš e-mail.",
"Enter the verification code sent to your email.": "Zadejte ověřovací kód zaslaný na e-mail",
"Events": "Události",
"Failed to load data": "Nepodařilo se načíst data",
"Failed to load latency data": "Nepodařilo se načíst data latence",
"Failed to load status data for this day": "Nepodařilo se načíst data stavu pro tento den",
"Failed to send verification code": "Nepodařilo se odeslat ověřovací kód",
"Failed to update preference": "Nepodařilo se aktualizovat nastavení",
"Failed to update preference": "Nepodařilo se uložit nastavení",
"Format": "Formát",
"Get badges for this monitor": "Získat odznaky pro tento monitor",
"Get notified about incidents and scheduled maintenance.": "Dostávejte upozornění na incidenty a plánovanou údržbu.",
"Get notified about incidents and scheduled maintenance.": "Dostávejte upozornění na incidenty a plánovanou údržbu",
"Get notified about incidents updates": "Dostávejte upozornění na aktualizace incidentů",
"Get notified about scheduled maintenance": "Dostávejte upozornění na plánovanou údržbu",
"Home": "Domů",
"IDENTIFIED": "IDENTIFIKOVÁNO",
"iFrame": "iFrame",
"Impact": "Dopad",
"incident": "incident",
"Incident": "Incident",
"Incident Updates": "Aktualizace incidentů",
"Incidents": "Incidenty",
"Included Monitors": "Monitorů zahrnuto",
"Included Monitors (%count)": "Zahrnuté monitory (%count)",
"INVESTIGATING": "VYŠETŘOVÁNÍ",
"Last Updated": "Naposledy aktualizováno",
"Latency": "Latence",
@@ -59,17 +62,18 @@
"Latency Over Time": "Latence v čase",
"Latency Trend": "Trend latence",
"Latest Latency": "Poslední latence",
"Latest Status": "Posled stav",
"Latest Status": "Naposledy zjištěný stav",
"Light": "Světlý",
"Live Status": "Aktuální stav",
"Loading your preferences...": "Načítám vaše nastavení...",
"Loading your preferences...": "Načítá nastavení...",
"maintenance": "údržba",
"Maintenance": "Údržba",
"MAINTENANCE": "ÚDRŽBA",
"Maintenance Updates": "Aktualizace údržby",
"Maintenances": "Údrždy",
"Maintenances": "Údržby",
"Major System Outage": "Závažný výpadek systému",
"Manage Site": "Spravovat stránku",
"Manage your notification preferences.": "Spravujte svá nastavení oznámení.",
"Manage your notification preferences.": "Spravujte nastavení oznámení",
"Max Latency": "Max. latence",
"maximum": "maximální",
"Maximum Latency": "Maximální latence",
@@ -78,32 +82,34 @@
"Minimum Latency": "Minimální latence",
"Minute-by-minute status data for this day": "Minutová data stavu pro tento den",
"MONITORING": "MONITOROVÁNÍ",
"Network error. Please try again.": "Chyba sítě. Zkuste to prosím znovu.",
"Network error. Please try again.": "Chyba sítě. Zkuste to znovu",
"No Events in %currentMonth": "V měsíci %currentMonth nejsou plánované žádné události",
"No events to show": "Žádné události k zobrazení",
"No incidents for this day": "Pro tento den nejsou evidovány žádné incidenty",
"No latency data available for this day": "Pro tento den nejsou k dispozici data latence",
"No latency data available for this day": "Pro tento den nejsou dostupná data latence",
"No maintenances for this day": "Pro tento den není naplánovaná žádná údržba",
"No monitors affected": "Žádné zasažené monitory",
"No monitors available.": "Žádné dostupné monitory.",
"No monitors available.": "Žádné dostupné monitory",
"No ongoing maintenances": "Žádná probíhající údržba",
"No past maintenances": "Žádná minulá údržba",
"No Status Available": "Stav není k dispozici",
"No upcoming maintenances": "Žádná nadcházející údržba",
"No Updates": "Žádné aktualizace",
"No updates yet": "Zatím bez aktualizací",
"No updates yet": "Zatím žádné aktualizace",
"NO_DATA": "Žádná data",
"Notifications": "Oznámení",
"One-time": "Jednorázově",
"Ongoing": "Probíhající",
"ONGOING": "PROBÍHAJÍCÍ",
"Ongoing Maintenances": "Probíhající údržby",
"Partial Degraded Performance": "Částečně zhoršený výkon",
"Operational": "V provozu",
"Partial Degraded Performance": "Částečně omezený provoz",
"Partial System Outage": "Částečný výpadek systému",
"Past": "Minulé",
"Per-Minute Status": "Stav po minutách",
"Pinging": "Pingování",
"Please enter a valid email address": "Zadejte platnou e-mailovou adresu",
"Please enter the 6-digit verification code": "Zadejte prosím 6místný ověřovací kód",
"Please enter the 6-digit verification code": "Zadejte 6místný ověřovací kód",
"Read less": "Zobrazit méně",
"Read more": "Zobrazit více",
"READY": "PŘIPRAVENO",
@@ -115,20 +121,20 @@
"Scheduled Windows": "Naplánované úlohy",
"Script": "Skript",
"Select Language": "Vyberte jazyk",
"Select latency metric to display": "Vyberte metriku latence k zobrazení",
"Select latency metric to display": "Vyberte metriku latence",
"Select Range": "Vyberte rozsah",
"Sending...": "Odesílání...",
"Standard": "Standardní",
"Start Time": "Začátek",
"Status": "Stav",
"Status Badge": "Odznak stavu",
"Status Embed": "Status pro vložení",
"Status Embed": "Stav pro vložení",
"Status history and latency trend": "Historie stavu a trend latence",
"Subscribe": "Odebírat",
"Subscribe": "Přihlásit se k odběru",
"Subscribe to Updates": "Odebírat aktualizace",
"Theme": "Motiv",
"There are no incidents or maintenances scheduled for this month.": "Na tento měsíc nejsou naplánované incidenty ani údržba.",
"There are no ongoing incidents or maintenance events.": "V tuto chvíli neprobíhají žádné incidenty ani údržba.",
"There are no incidents or maintenances scheduled for this month.": "Na tento měsíc nejsou naplánované žádné incidenty ani údržby",
"There are no ongoing incidents or maintenance events.": "V tuto chvíli neprobíhají žádné incidenty ani údržba",
"Timeline": "Časová osa",
"Total Incidents": "Celkem incidentů",
"Total Maintenances": "Celkem údržeb",
@@ -146,6 +152,6 @@
"Verification failed": "Ověření se nezdařilo",
"Verify": "Ověřit",
"Verifying": "Ověřování",
"We sent a 6-digit code to": "Poslali jsme 6místný kód na"
"We sent a 6-digit code to": "Odeslali jsme 6místný kód na"
}
}
+16 -9
View File
@@ -17,8 +17,12 @@
"Day": "Tag",
"Day Uptime": "Tagesverfügbarkeit",
"Days": "Tage",
"Degraded": "Beeinträchtigt",
"DEGRADED": "BEEINTRÄCHTIGT",
"Degraded Performance": "Beeinträchtigte Leistung",
"Didn't receive the code? Resend": "Sie haben den Code nicht erhalten? ",
"Down": "Ausgefallen",
"DOWN": "AUSGEFALLEN",
"Duration": "Dauer",
"Email address": "E-Mail-Adresse",
"Embed Monitor": "Monitor einbetten",
@@ -35,12 +39,14 @@
"Get notified about incidents and scheduled maintenance.": "Lassen Sie sich über Vorfälle und geplante Wartungsarbeiten benachrichtigen.",
"Get notified about incidents updates": "Lassen Sie sich über Aktualisierungen von Vorfällen benachrichtigen",
"Get notified about scheduled maintenance": "Lassen Sie sich über geplante Wartungsarbeiten benachrichtigen",
"IDENTIFIED": "IDENTIFIED",
"IDENTIFIED": "IDENTIFIZIERT",
"iFrame": "iFrame",
"Impact": "Auswirkungen",
"incident": "Vorfall",
"Incident Updates": "Vorfallaktualisierungen",
"Incidents": "Vorfälle",
"Included Monitors": "Enthaltene Monitore",
"Included Monitors (%count)": "Enthaltene Monitore (%count)",
"INVESTIGATING": "WIRD UNTERSUCHT",
"Last Updated": "Zuletzt aktualisiert",
"Latency": "Latenz",
"Latency Embed": "Latenz-Einbettung",
@@ -51,6 +57,8 @@
"Light": "Hell",
"Live Status": "Live-Status",
"Loading your preferences...": "Deine Einstellungen werden geladen...",
"maintenance": "Wartung",
"MAINTENANCE": "WARTUNG",
"Maintenance Updates": "Wartungsaktualisierungen",
"Maintenances": "Wartungen",
"Major System Outage": "Schwerwiegender Systemausfall",
@@ -61,7 +69,7 @@
"Min Latency": "Min. Latenz",
"Minimum Latency": "Min. Latenz",
"Minute-by-minute status data for this day": "Minutenweise Statusdaten für diesen Tag",
"MONITORING": "MONITORING",
"MONITORING": "WIRD ÜBERWACHT",
"Network error. Please try again.": "Netzwerkfehler. ",
"No Events in %currentMonth": "Keine Ereignisse in %currentMonth",
"No events to show": "Keine Ereignisse zum Anzeigen",
@@ -79,6 +87,7 @@
"Notifications": "Benachrichtigungen",
"One-time": "Einmalig",
"Ongoing": "Laufend",
"Operational": "Betriebsbereit",
"Partial Degraded Performance": "Teilweise beeinträchtigte Leistung",
"Partial System Outage": "Teilweiser Systemausfall",
"Past": "Vergangenheit",
@@ -90,7 +99,7 @@
"Read more": "Mehr lesen",
"READY": "BEREIT",
"Recurring": "Wiederkehrend",
"RESOLVED": "RESOLVED",
"RESOLVED": "BEHOBEN",
"SCHEDULED": "GEPLANT",
"Scheduled Events (%count)": "Geplante Ereignisse (%count)",
"Script": "Skript",
@@ -112,7 +121,9 @@
"Total Maintenances": "Gesamtwartungen",
"Under Maintenance": "Unter Wartung",
"Unknown impact": "Unbekannte Auswirkung",
"UP": "AKTIV",
"Upcoming": "Demnächst",
"Update Incident": "Vorfall aktualisieren",
"Update Maintenance": "Wartung aktualisieren",
"Updates": "Aktualisierungen",
"Updates (%count)": "Aktualisierungen (%count)",
@@ -121,10 +132,6 @@
"Verification failed": "Die Überprüfung ist fehlgeschlagen",
"Verify": "Verifizieren",
"Verifying": "Verifizieren",
"We sent a 6-digit code to": "Wir haben einen 6-stelligen Code an gesendet",
"INVESTIGATING": "WIRD UNTERSUCHT",
"IDENTIFIED": "IDENTIFIZIERT",
"MONITORING": "WIRD ÜBERWACHT",
"RESOLVED": "BEHOBEN"
"We sent a 6-digit code to": "Wir haben einen 6-stelligen Code an gesendet"
}
}
+11 -1
View File
@@ -17,8 +17,12 @@
"Day": "Dag",
"Day Uptime": "Dag oppetid",
"Days": "dage",
"Degraded": "Forringet",
"DEGRADED": "FORRINGET",
"Degraded Performance": "Nedsat ydeevne",
"Didn't receive the code? Resend": "Modtog du ikke koden? ",
"Down": "Nede",
"DOWN": "NEDE",
"Duration": "Varighed",
"Email address": "E-mailadresse",
"Embed Monitor": "Integrer skærm",
@@ -38,9 +42,10 @@
"IDENTIFIED": "IDENTIFIED",
"iFrame": "iFrame",
"Impact": "Indvirkning",
"incident": "Hændelse",
"Incident Updates": "Hændelsesopdateringer",
"Incidents": "Hændelser",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Sidst opdateret",
"Latency": "Latency",
@@ -52,6 +57,8 @@
"Light": "Lys",
"Live Status": "Live status",
"Loading your preferences...": "Indlæser dine præferencer...",
"maintenance": "Vedligeholdelse",
"MAINTENANCE": "VEDLIGEHOLDELSE",
"Maintenance Updates": "Vedligeholdelsesopdateringer",
"Maintenances": "Vedligeholdelse",
"Major System Outage": "Større systemnedbrud",
@@ -80,6 +87,7 @@
"Notifications": "Notifikationer",
"One-time": "Engangs",
"Ongoing": "Løbende",
"Operational": "Operationel",
"Partial Degraded Performance": "Delvist nedsat ydeevne",
"Partial System Outage": "Delvist systemnedbrud",
"Past": "Forbi",
@@ -113,7 +121,9 @@
"Total Maintenances": "Samlet vedligeholdelse",
"Under Maintenance": "Under Vedligeholdelse",
"Unknown impact": "Ukendt påvirkning",
"UP": "OPPE",
"Upcoming": "Kommende",
"Update Incident": "Opdater hændelse",
"Update Maintenance": "Opdater vedligeholdelse",
"Updates": "Opdateringer",
"Updates (%count)": "Opdateringer (%count)",
+11 -1
View File
@@ -17,8 +17,12 @@
"Day": "Day",
"Day Uptime": "Day Uptime",
"Days": "Days",
"Degraded": "Degraded",
"DEGRADED": "DEGRADED",
"Degraded Performance": "Degraded Performance",
"Didn't receive the code? Resend": "Didn't receive the code? Resend",
"Down": "Down",
"DOWN": "DOWN",
"Duration": "Duration",
"Email address": "Email address",
"Embed Monitor": "Embed Monitor",
@@ -38,9 +42,10 @@
"IDENTIFIED": "IDENTIFIED",
"iFrame": "iFrame",
"Impact": "Impact",
"incident": "Incident",
"Incident Updates": "Incident Updates",
"Incidents": "Incidents",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Last Updated",
"Latency": "Latency",
@@ -52,6 +57,8 @@
"Light": "Light",
"Live Status": "Live Status",
"Loading your preferences...": "Loading your preferences...",
"maintenance": "Maintenance",
"MAINTENANCE": "MAINTENANCE",
"Maintenance Updates": "Maintenance Updates",
"Maintenances": "Maintenances",
"Major System Outage": "Major System Outage",
@@ -80,6 +87,7 @@
"Notifications": "Notifications",
"One-time": "One-time",
"Ongoing": "Ongoing",
"Operational": "Operational",
"Partial Degraded Performance": "Partial Degraded Performance",
"Partial System Outage": "Partial System Outage",
"Past": "Past",
@@ -113,7 +121,9 @@
"Total Maintenances": "Total Maintenances",
"Under Maintenance": "Under Maintenance",
"Unknown impact": "Unknown impact",
"UP": "UP",
"Upcoming": "Upcoming",
"Update Incident": "Update Incident",
"Update Maintenance": "Update Maintenance",
"Updates": "Updates",
"Updates (%count)": "Updates (%count)",
+11 -1
View File
@@ -17,8 +17,12 @@
"Day": "Día",
"Day Uptime": "Tiempo de actividad del día",
"Days": "Días",
"Degraded": "Degradado",
"DEGRADED": "DEGRADADO",
"Degraded Performance": "Rendimiento degradado",
"Didn't receive the code? Resend": "¿No recibiste el código? ",
"Down": "Caído",
"DOWN": "CAÍDO",
"Duration": "Duración",
"Email address": "Dirección de correo electrónico",
"Embed Monitor": "Monitor integrado",
@@ -38,9 +42,10 @@
"IDENTIFIED": "IDENTIFIED",
"iFrame": "marco flotante",
"Impact": "Impacto",
"incident": "Incidente",
"Incident Updates": "Actualizaciones de incidentes",
"Incidents": "Incidentes",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Última actualización",
"Latency": "Estado latente",
@@ -52,6 +57,8 @@
"Light": "Luz",
"Live Status": "Estado en vivo",
"Loading your preferences...": "Cargando tus preferencias...",
"maintenance": "Mantenimiento",
"MAINTENANCE": "MANTENIMIENTO",
"Maintenance Updates": "Actualizaciones de mantenimiento",
"Maintenances": "Mantenimientos",
"Major System Outage": "Interrupción importante del sistema",
@@ -80,6 +87,7 @@
"Notifications": "Notificaciones",
"One-time": "una sola vez",
"Ongoing": "En curso",
"Operational": "Operativo",
"Partial Degraded Performance": "Rendimiento parcialmente degradado",
"Partial System Outage": "Interrupción parcial del sistema",
"Past": "Pasado",
@@ -113,7 +121,9 @@
"Total Maintenances": "Mantenimientos totales",
"Under Maintenance": "En mantenimiento",
"Unknown impact": "Impacto desconocido",
"UP": "ACTIVO",
"Upcoming": "Próximo",
"Update Incident": "Actualizar incidente",
"Update Maintenance": "Actualizar mantenimiento",
"Updates": "Actualizaciones",
"Updates (%count)": "Actualizaciones (%count)",
+11 -1
View File
@@ -17,8 +17,12 @@
"Day": "روز",
"Day Uptime": "تایم روز",
"Days": "روزها",
"Degraded": "کاهش یافته",
"DEGRADED": "کاهش یافته",
"Degraded Performance": "عملکرد کاهش‌یافته",
"Didn't receive the code? Resend": "کد را دریافت نکردید؟ ",
"Down": "از کار افتاده",
"DOWN": "قطع",
"Duration": "مدت",
"Email address": "آدرس ایمیل",
"Embed Monitor": "تعبیه مانیتور",
@@ -38,9 +42,10 @@
"IDENTIFIED": "IDENTIFIED",
"iFrame": "آی فریم",
"Impact": "تاثیر",
"incident": "رخداد",
"Incident Updates": "به روز رسانی حادثه",
"Incidents": "حوادث",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "آخرین به روز رسانی",
"Latency": "تأخیر",
@@ -52,6 +57,8 @@
"Light": "نور",
"Live Status": "وضعیت زنده",
"Loading your preferences...": "در حال بارگیری تنظیمات برگزیده شما...",
"maintenance": "نگهداری",
"MAINTENANCE": "تعمیر و نگهداری",
"Maintenance Updates": "به روز رسانی های تعمیر و نگهداری",
"Maintenances": "تعمیر و نگهداری",
"Major System Outage": "قطعی عمده سیستم",
@@ -80,6 +87,7 @@
"Notifications": "اعلان‌ها",
"One-time": "یک بار",
"Ongoing": "در حال انجام است",
"Operational": "عملیاتی",
"Partial Degraded Performance": "کاهش عملکرد جزئی",
"Partial System Outage": "قطعی جزئی سیستم",
"Past": "گذشته",
@@ -113,7 +121,9 @@
"Total Maintenances": "کل تعمیر و نگهداری",
"Under Maintenance": "تحت تعمیر و نگهداری",
"Unknown impact": "تاثیر نامعلوم",
"UP": "فعال",
"Upcoming": "آینده",
"Update Incident": "به‌روزرسانی رویداد",
"Update Maintenance": "به‌روزرسانی نگهداری",
"Updates": "به‌روزرسانی‌ها",
"Updates (%count)": "به‌روزرسانی‌ها (%count)",
+11 -1
View File
@@ -17,8 +17,12 @@
"Day": "Jour",
"Day Uptime": "Disponibilité journalière",
"Days": "Jours",
"Degraded": "Dégradé",
"DEGRADED": "DÉGRADÉ",
"Degraded Performance": "Performance dégradée",
"Didn't receive the code? Resend": "Vous n'avez pas reçu le code ? ",
"Down": "Hors service",
"DOWN": "EN PANNE",
"Duration": "Durée",
"Email address": "Adresse email",
"Embed Monitor": "Intégrer le moniteur",
@@ -38,9 +42,10 @@
"IDENTIFIED": "IDENTIFIED",
"iFrame": "iFrame",
"Impact": "Impact",
"incident": "Incident",
"Incident Updates": "Mises à jour des incidents",
"Incidents": "Incidents",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Dernière mise à jour",
"Latency": "Latence",
@@ -52,6 +57,8 @@
"Light": "Lumière",
"Live Status": "Statut en direct",
"Loading your preferences...": "Chargement de vos préférences...",
"maintenance": "Maintenance",
"MAINTENANCE": "MAINTENANCE",
"Maintenance Updates": "Mises à jour de maintenance",
"Maintenances": "Entretiens",
"Major System Outage": "Panne majeure du système",
@@ -80,6 +87,7 @@
"Notifications": "Alertes",
"One-time": "Une fois",
"Ongoing": "En cours",
"Operational": "Opérationnel",
"Partial Degraded Performance": "Performance partiellement dégradée",
"Partial System Outage": "Panne partielle du système",
"Past": "Passé",
@@ -113,7 +121,9 @@
"Total Maintenances": "Entretiens totaux",
"Under Maintenance": "En maintenance",
"Unknown impact": "Impact inconnu",
"UP": "OPÉRATIONNEL",
"Upcoming": "Prochain",
"Update Incident": "Mettre à jour l'incident",
"Update Maintenance": "Mettre à jour la maintenance",
"Updates": "Mises à jour",
"Updates (%count)": "Mises à jour (%count)",
+11 -1
View File
@@ -17,8 +17,12 @@
"Day": "दिन",
"Day Uptime": "दिन का अपटाइम",
"Days": "दिन",
"Degraded": "अवनत",
"DEGRADED": "अवक्रमित",
"Degraded Performance": "प्रदर्शन में गिरावट",
"Didn't receive the code? Resend": "कोड नहीं मिला? फिर से भेजें",
"Down": "बंद",
"DOWN": "बंद",
"Duration": "अवधि",
"Email address": "ईमेल पता",
"Embed Monitor": "मॉनिटर एम्बेड करें",
@@ -38,9 +42,10 @@
"IDENTIFIED": "पहचाना गया",
"iFrame": "iFrame",
"Impact": "प्रभाव",
"incident": "घटना",
"Incident Updates": "घटना अपडेट",
"Incidents": "घटनाएँ",
"Included Monitors": "शामिल मॉनिटर",
"Included Monitors (%count)": "शामिल मॉनिटर (%count)",
"INVESTIGATING": "जाँच कर रहे हैं",
"Last Updated": "अंतिम अपडेट",
"Latency": "लेटेंसी",
@@ -52,6 +57,8 @@
"Light": "लाइट",
"Live Status": "लाइव स्थिति",
"Loading your preferences...": "आपकी प्राथमिकताएँ लोड हो रही हैं...",
"maintenance": "रखरखाव",
"MAINTENANCE": "रखरखाव",
"Maintenance Updates": "रखरखाव अपडेट",
"Maintenances": "रखरखाव",
"Major System Outage": "सिस्टम का बड़ा आउटेज",
@@ -80,6 +87,7 @@
"Notifications": "सूचनाएँ",
"One-time": "एक बार",
"Ongoing": "चल रहा है",
"Operational": "चालू",
"Partial Degraded Performance": "आंशिक प्रदर्शन गिरावट",
"Partial System Outage": "सिस्टम का आंशिक आउटेज.",
"Past": "बीता हुआ",
@@ -113,7 +121,9 @@
"Total Maintenances": "कुल रखरखाव",
"Under Maintenance": "रखरखाव जारी है",
"Unknown impact": "अज्ञात प्रभाव",
"UP": "चालू",
"Upcoming": "आने वाला",
"Update Incident": "घटना अपडेट करें",
"Update Maintenance": "रखरखाव अपडेट करें",
"Updates": "अपडेट्स",
"Updates (%count)": "अपडेट (%count)",
+11 -1
View File
@@ -17,8 +17,12 @@
"Day": "Giorno",
"Day Uptime": "Tempo di attività giornaliero",
"Days": "Giorni",
"Degraded": "Degradato",
"DEGRADED": "DEGRADATO",
"Degraded Performance": "Prestazioni degradate",
"Didn't receive the code? Resend": "Non hai ricevuto il codice? ",
"Down": "Non disponibile",
"DOWN": "NON DISPONIBILE",
"Duration": "Durata",
"Email address": "Indirizzo e-mail",
"Embed Monitor": "Incorpora monitoraggio",
@@ -38,9 +42,10 @@
"IDENTIFIED": "IDENTIFIED",
"iFrame": "iFrame",
"Impact": "Impatto",
"incident": "Incidente",
"Incident Updates": "Aggiornamenti sugli incidenti",
"Incidents": "Incidenti",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Ultimo aggiornamento",
"Latency": "Latenza",
@@ -52,6 +57,8 @@
"Light": "Leggero",
"Live Status": "Stato in tempo reale",
"Loading your preferences...": "Caricamento delle tue preferenze...",
"maintenance": "Manutenzione",
"MAINTENANCE": "MANUTENZIONE",
"Maintenance Updates": "Aggiornamenti sulla manutenzione",
"Maintenances": "Manutenzioni",
"Major System Outage": "Interruzione grave del sistema",
@@ -80,6 +87,7 @@
"Notifications": "Notifiche",
"One-time": "Una volta",
"Ongoing": "In corso",
"Operational": "Operativo",
"Partial Degraded Performance": "Prestazioni parzialmente degradate",
"Partial System Outage": "Interruzione parziale del sistema",
"Past": "Passato",
@@ -113,7 +121,9 @@
"Total Maintenances": "Manutenzioni totali",
"Under Maintenance": "In manutenzione",
"Unknown impact": "Impatto sconosciuto",
"UP": "ATTIVO",
"Upcoming": "Prossimamente",
"Update Incident": "Aggiorna incidente",
"Update Maintenance": "Aggiorna manutenzione",
"Updates": "Aggiornamenti",
"Updates (%count)": "Aggiornamenti (%count)",
+11 -1
View File
@@ -17,8 +17,12 @@
"Day": "日",
"Day Uptime": "一日の稼働時間",
"Days": "日数",
"Degraded": "低下",
"DEGRADED": "劣化",
"Degraded Performance": "パフォーマンスの低下",
"Didn't receive the code? Resend": "コードを受け取っていませんか?",
"Down": "停止",
"DOWN": "障害",
"Duration": "間隔",
"Email address": "電子メールアドレス",
"Embed Monitor": "埋め込みモニター",
@@ -38,9 +42,10 @@
"IDENTIFIED": "IDENTIFIED",
"iFrame": "iFrame",
"Impact": "インパクト",
"incident": "インシデント",
"Incident Updates": "インシデントの最新情報",
"Incidents": "事件",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "最終更新日",
"Latency": "レイテンシー",
@@ -52,6 +57,8 @@
"Light": "ライト",
"Live Status": "ライブステータス",
"Loading your preferences...": "設定を読み込んでいます...",
"maintenance": "メンテナンス",
"MAINTENANCE": "メンテナンス",
"Maintenance Updates": "メンテナンスアップデート",
"Maintenances": "メンテナンス",
"Major System Outage": "大規模なシステム障害",
@@ -80,6 +87,7 @@
"Notifications": "通知",
"One-time": "一度",
"Ongoing": "進行中",
"Operational": "稼働中",
"Partial Degraded Performance": "部分的なパフォーマンス低下",
"Partial System Outage": "部分的なシステム障害。",
"Past": "過去",
@@ -113,7 +121,9 @@
"Total Maintenances": "トータルメンテナンス",
"Under Maintenance": "メンテナンス中",
"Unknown impact": "未知の影響",
"UP": "正常",
"Upcoming": "今後の予定",
"Update Incident": "インシデントを更新",
"Update Maintenance": "メンテナンスを更新",
"Updates": "更新",
"Updates (%count)": "アップデート (%count)",
+11 -1
View File
@@ -17,8 +17,12 @@
"Day": "낮",
"Day Uptime": "일일 가동 시간",
"Days": "날",
"Degraded": "저하",
"DEGRADED": "성능 저하",
"Degraded Performance": "성능 저하",
"Didn't receive the code? Resend": "코드를 받지 못하셨나요? 재전송",
"Down": "중단",
"DOWN": "장애",
"Duration": "지속",
"Email address": "이메일 주소",
"Embed Monitor": "모니터 내장",
@@ -38,9 +42,10 @@
"IDENTIFIED": "IDENTIFIED",
"iFrame": "아이프레임",
"Impact": "영향",
"incident": "인시던트",
"Incident Updates": "사고 업데이트",
"Incidents": "사건",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "마지막 업데이트",
"Latency": "숨어 있음",
@@ -52,6 +57,8 @@
"Light": "빛",
"Live Status": "실시간 현황",
"Loading your preferences...": "환경설정 로드 중...",
"maintenance": "유지보수",
"MAINTENANCE": "점검 중",
"Maintenance Updates": "유지보수 업데이트",
"Maintenances": "유지보수",
"Major System Outage": "대규모 시스템 장애",
@@ -80,6 +87,7 @@
"Notifications": "알림",
"One-time": "일회성",
"Ongoing": "전진",
"Operational": "정상",
"Partial Degraded Performance": "부분적 성능 저하",
"Partial System Outage": "부분적 시스템 장애.",
"Past": "과거",
@@ -113,7 +121,9 @@
"Total Maintenances": "총 유지보수",
"Under Maintenance": "유지보수 중",
"Unknown impact": "알 수 없는 영향",
"UP": "정상",
"Upcoming": "예정",
"Update Incident": "인시던트 업데이트",
"Update Maintenance": "유지보수 업데이트",
"Updates": "업데이트",
"Updates (%count)": "업데이트(%count)",
+11 -1
View File
@@ -17,8 +17,12 @@
"Day": "Dag",
"Day Uptime": "Dag oppetid",
"Days": "Dager",
"Degraded": "Degradert",
"DEGRADED": "FORRINGET",
"Degraded Performance": "Redusert ytelse",
"Didn't receive the code? Resend": "Fikk du ikke koden? Send på nytt",
"Down": "Nede",
"DOWN": "NEDE",
"Duration": "Varighet",
"Email address": "E-postadresse",
"Embed Monitor": "Bygg inn monitor",
@@ -38,9 +42,10 @@
"IDENTIFIED": "IDENTIFIED",
"iFrame": "iFrame",
"Impact": "Påvirkning",
"incident": "Hendelse",
"Incident Updates": "Hendelsesoppdateringer",
"Incidents": "Hendelser",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Sist oppdatert",
"Latency": "Latens",
@@ -52,6 +57,8 @@
"Light": "Lys",
"Live Status": "Live-status",
"Loading your preferences...": "Laster inn innstillingene dine ...",
"maintenance": "Vedlikehold",
"MAINTENANCE": "VEDLIKEHOLD",
"Maintenance Updates": "Vedlikeholdsoppdateringer",
"Maintenances": "Vedlikehold",
"Major System Outage": "Større systemutfall",
@@ -80,6 +87,7 @@
"Notifications": "Varsler",
"One-time": "En gang",
"Ongoing": "Pågående",
"Operational": "Operativ",
"Partial Degraded Performance": "Delvis redusert ytelse",
"Partial System Outage": "Delvis systemutfall",
"Past": "Forbi",
@@ -113,7 +121,9 @@
"Total Maintenances": "Totalt vedlikehold",
"Under Maintenance": "Under Vedlikehold",
"Unknown impact": "Ukjent påvirkning",
"UP": "OPPE",
"Upcoming": "Kommende",
"Update Incident": "Oppdater hendelse",
"Update Maintenance": "Oppdater vedlikehold",
"Updates": "Oppdateringer",
"Updates (%count)": "Oppdateringer (%count)",
+11 -1
View File
@@ -17,8 +17,12 @@
"Day": "Dag",
"Day Uptime": "Dag uptime",
"Days": "Dagen",
"Degraded": "Verstoord",
"DEGRADED": "VERSLECHTERD",
"Degraded Performance": "Verminderde prestaties",
"Didn't receive the code? Resend": "Heb je de code niet ontvangen? ",
"Down": "Niet bereikbaar",
"DOWN": "OFFLINE",
"Duration": "Duur",
"Email address": "E-mailadres",
"Embed Monitor": "Monitor insluiten",
@@ -38,9 +42,10 @@
"IDENTIFIED": "IDENTIFIED",
"iFrame": "iFrame",
"Impact": "Invloed",
"incident": "Incident",
"Incident Updates": "Incidentupdates",
"Incidents": "Incidenten",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Laatst bijgewerkt",
"Latency": "Latentie",
@@ -52,6 +57,8 @@
"Light": "Licht",
"Live Status": "Live-status",
"Loading your preferences...": "Uw voorkeuren laden...",
"maintenance": "Onderhoud",
"MAINTENANCE": "ONDERHOUD",
"Maintenance Updates": "Onderhoudsupdates",
"Maintenances": "Onderhouden",
"Major System Outage": "Grote systeemstoring",
@@ -80,6 +87,7 @@
"Notifications": "Meldingen",
"One-time": "Eenmalig",
"Ongoing": "Lopend",
"Operational": "Operationeel",
"Partial Degraded Performance": "Gedeeltelijk verminderde prestaties",
"Partial System Outage": "Gedeeltelijke systeemstoring",
"Past": "Verleden",
@@ -113,7 +121,9 @@
"Total Maintenances": "Totaal onderhoud",
"Under Maintenance": "Onder Onderhoud",
"Unknown impact": "Onbekende impact",
"UP": "ACTIEF",
"Upcoming": "Aankomend",
"Update Incident": "Incident bijwerken",
"Update Maintenance": "Onderhoud bijwerken",
"Updates": "Actualisaties",
"Updates (%count)": "Updates (%count)",
+11 -1
View File
@@ -17,8 +17,12 @@
"Day": "Dzień",
"Day Uptime": "Dzień sprawności",
"Days": "Dni",
"Degraded": "Pogorszony",
"DEGRADED": "POGORSZONY",
"Degraded Performance": "Obniżona wydajność",
"Didn't receive the code? Resend": "Nie otrzymałeś kodu? ",
"Down": "Niedostępny",
"DOWN": "AWARIA",
"Duration": "Czas trwania",
"Email address": "Adres e-mail",
"Embed Monitor": "Wstaw monitor",
@@ -38,9 +42,10 @@
"IDENTIFIED": "IDENTIFIED",
"iFrame": "iFramka",
"Impact": "Uderzenie",
"incident": "Incydent",
"Incident Updates": "Aktualizacje incydentów",
"Incidents": "Incydenty",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Ostatnia aktualizacja",
"Latency": "Utajenie",
@@ -52,6 +57,8 @@
"Light": "Światło",
"Live Status": "Stan na żywo",
"Loading your preferences...": "Ładowanie Twoich preferencji...",
"maintenance": "Konserwacja",
"MAINTENANCE": "KONSERWACJA",
"Maintenance Updates": "Aktualizacje konserwacyjne",
"Maintenances": "Konserwacje",
"Major System Outage": "Poważna awaria systemu",
@@ -80,6 +87,7 @@
"Notifications": "Powiadomienia",
"One-time": "Jednorazowy",
"Ongoing": "Bieżący",
"Operational": "Operacyjny",
"Partial Degraded Performance": "Częściowo obniżona wydajność",
"Partial System Outage": "Częściowa awaria systemu",
"Past": "Przeszłość",
@@ -113,7 +121,9 @@
"Total Maintenances": "Całkowita konserwacja",
"Under Maintenance": "W ramach konserwacji",
"Unknown impact": "Nieznany wpływ",
"UP": "DZIAŁA",
"Upcoming": "Nadchodzące",
"Update Incident": "Aktualizuj incydent",
"Update Maintenance": "Aktualizuj konserwację",
"Updates": "Aktualizacje",
"Updates (%count)": "Aktualizacje (%count)",
+11 -1
View File
@@ -17,8 +17,12 @@
"Day": "Dia",
"Day Uptime": "Tempo de atividade diurno",
"Days": "Dias",
"Degraded": "Degradado",
"DEGRADED": "DEGRADADO",
"Degraded Performance": "Desempenho degradado",
"Didn't receive the code? Resend": "Não recebeu o código? ",
"Down": "Fora do ar",
"DOWN": "FORA DO AR",
"Duration": "Duração",
"Email address": "Endereço de email",
"Embed Monitor": "Incorporar monitor",
@@ -38,9 +42,10 @@
"IDENTIFIED": "IDENTIFIED",
"iFrame": "iFrame",
"Impact": "Impacto",
"incident": "Incidente",
"Incident Updates": "Atualizações de incidentes",
"Incidents": "Incidentes",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Última atualização",
"Latency": "Latência",
@@ -52,6 +57,8 @@
"Light": "Luz",
"Live Status": "Status ao vivo",
"Loading your preferences...": "Carregando suas preferências...",
"maintenance": "Manutenção",
"MAINTENANCE": "MANUTENÇÃO",
"Maintenance Updates": "Atualizações de manutenção",
"Maintenances": "Manutenção",
"Major System Outage": "Indisponibilidade grave do sistema",
@@ -80,6 +87,7 @@
"Notifications": "Notificações",
"One-time": "Único",
"Ongoing": "Em andamento",
"Operational": "Operacional",
"Partial Degraded Performance": "Desempenho parcialmente degradado",
"Partial System Outage": "Indisponibilidade parcial do sistema",
"Past": "Passado",
@@ -113,7 +121,9 @@
"Total Maintenances": "Manutenção total",
"Under Maintenance": "Em manutenção",
"Unknown impact": "Impacto desconhecido",
"UP": "ATIVO",
"Upcoming": "Por vir",
"Update Incident": "Atualizar incidente",
"Update Maintenance": "Atualizar manutenção",
"Updates": "Atualizações",
"Updates (%count)": "Atualizações (%count)",
+11 -1
View File
@@ -18,8 +18,12 @@
"Day": "Day",
"Day Uptime": "Время работы за день",
"Days": "Days",
"Degraded": "Деградация",
"DEGRADED": "УХУДШЕНИЕ",
"Degraded Performance": "Снижение производительности",
"Didn't receive the code? Resend": "Не получили код? Отправить повторно",
"Down": "Недоступен",
"DOWN": "НЕ РАБОТАЕТ",
"Duration": "Продолжительность",
"Email address": "Адрес электронной почты",
"Embed Monitor": "Встроить монитор",
@@ -39,9 +43,10 @@
"IDENTIFIED": "IDENTIFIED",
"iFrame": "iFrame",
"Impact": "Влияние",
"incident": "Инцидент",
"Incident Updates": "Обновления инцидентов",
"Incidents": "Инциденты",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Последнее обновление",
"Latency": "Задержка",
@@ -53,6 +58,8 @@
"Light": "Светлая",
"Live Status": "Статус в реальном времени",
"Loading your preferences...": "Загрузка настроек...",
"maintenance": "Обслуживание",
"MAINTENANCE": "ОБСЛУЖИВАНИЕ",
"Maintenance Updates": "Обновления обслуживания",
"Maintenances": "Обслуживание",
"Major System Outage": "Крупный сбой системы",
@@ -81,6 +88,7 @@
"Notifications": "Уведомления",
"One-time": "Одноразовое",
"Ongoing": "Текущие",
"Operational": "Работает",
"Partial Degraded Performance": "Частичное снижение производительности",
"Partial System Outage": "Частичный сбой системы",
"Past": "Прошедшие",
@@ -114,7 +122,9 @@
"Total Maintenances": "Total Maintenances",
"Under Maintenance": "На обслуживании",
"Unknown impact": "Неизвестное влияние",
"UP": "РАБОТАЕТ",
"Upcoming": "Предстоящие",
"Update Incident": "Обновить инцидент",
"Update Maintenance": "Обновить обслуживание",
"Updates": "Обновления",
"Updates (%count)": "Обновления (%count)",
+36 -30
View File
@@ -6,7 +6,7 @@
"30 Days": "30 dní",
"7 Days": "7 dní",
"90 Days": "90 dní",
"Affected Monitors (%count)": "Ovládacie monitory (%count)",
"Affected Monitors (%count)": "Ovplyvnené monitory (%count)",
"All Systems Operational": "Všetky systémy sú v prevádzke",
"average": "priemerná",
"Average Latency": "Priemerná latencia",
@@ -22,54 +22,58 @@
"Day": "Deň",
"Day Uptime": "Denná dostupnosť",
"Days": "Dní",
"DEGRADED": "ZHORŠENÝ",
"Degraded Performance": "Zhoršený výkon",
"Didn't receive the code? Resend": "Nedostali ste kód? Odoslať znova",
"DOWN": "VÝPADOK",
"Degraded": "Obmedzený",
"DEGRADED": "OBMEDZENÝ",
"Degraded Performance": "Znížený výkon",
"Didn't receive the code? Resend": "Neprišiel kód? Odoslať znova",
"Down": "Nedostupné",
"DOWN": "NEDOSTUPNÉ",
"Duration": "Trvanie",
"Edit Monitor": "Upraviť monitor",
"Email address": "E-mailová adresa",
"Embed Monitor": "Vložiť monitor",
"Embed this monitor in your website or app": "Vložte tento monitor na svoj web alebo do aplikácie",
"Embed this monitor in your website or app": "Vložte tento monitor na web alebo do aplikácie",
"End Time": "Koniec",
"Enter the verification code sent to your email.": "Zadajte overovací kód zaslaný na váš e-mail.",
"Enter the verification code sent to your email.": "Zadajte overovací kód zaslaný na e-mail",
"Events": "Udalosti",
"Failed to load data": "Nepodarilo sa načítať dáta",
"Failed to load latency data": "Nepodarilo sa načítať dáta latencie",
"Failed to load status data for this day": "Nepodarilo sa načítať dáta stavu pre tento deň",
"Failed to load status data for this day": "Nepodarilo sa načítať stavové dáta pre tento deň",
"Failed to send verification code": "Nepodarilo sa odoslať overovací kód",
"Failed to update preference": "Nepodarilo sa aktualizovať nastavenia",
"Failed to update preference": "Nepodarilo sa uložiť nastavenie",
"Format": "Formát",
"Get badges for this monitor": "Získať odznaky pre tento monitor",
"Get notified about incidents and scheduled maintenance.": "Dostávajte upozornenia na incidenty a plánovanú údržbu.",
"Get notified about incidents and scheduled maintenance.": "Dostávajte upozornenia na incidenty a plánovanú údržbu",
"Get notified about incidents updates": "Dostávajte upozornenia na aktualizácie incidentov",
"Get notified about scheduled maintenance": "Dostávajte upozornenia na plánovanú údržbu",
"Home": "Domov",
"IDENTIFIED": "IDENTIFIKOVANÉ",
"iFrame": "iFrame",
"Impact": "Dopad",
"incident": "incident",
"Incident": "Incident",
"Incident Updates": "Aktualizácie incidentov",
"Incidents": "Incidenty",
"Included Monitors": "Monitorov zahrnuté",
"INVESTIGATING": "VYŠETROVANIE",
"Included Monitors (%count)": "Zahrnuté monitory (%count)",
"INVESTIGATING": "PREBIEHA VYŠETROVANIE",
"Last Updated": "Naposledy aktualizované",
"Latency": "Latencia",
"Latency Embed": "Vložená latencia",
"Latency Over Time": "Latencia v čase",
"Latency Trend": "Trend latencie",
"Latest Latency": "Posledná latencia",
"Latest Status": "Posledný stav",
"Latest Status": "Naposledy zistený stav",
"Light": "Svetlý",
"Live Status": "Aktuálny stav",
"Loading your preferences...": "Načítavam vaše nastavenia...",
"Loading your preferences...": "Načítavanie nastavení...",
"maintenance": "údržba",
"Maintenance": "Údržba",
"MAINTENANCE": "ÚDRŽBA",
"Maintenance Updates": "Aktualizácie údržby",
"Maintenances": "Údržby",
"Major System Outage": "Závažný výpadok systému",
"Manage Site": "Spravovať stránku",
"Manage your notification preferences.": "Spravujte svoje nastavenia oznámení.",
"Manage your notification preferences.": "Spravujte nastavenia upozornení",
"Max Latency": "Max. latencia",
"maximum": "maximálna",
"Maximum Latency": "Maximálna latencia",
@@ -78,26 +82,28 @@
"Minimum Latency": "Minimálna latencia",
"Minute-by-minute status data for this day": "Minútové dáta stavu pre tento deň",
"MONITORING": "MONITOROVANIE",
"Network error. Please try again.": "Chyba siete. Skúste to znova.",
"No Events in %currentMonth": "V mesiaci %currentMonth nie sú plánované žiadne udalosti",
"Network error. Please try again.": "Chyba siete. Skúste to znova",
"No Events in %currentMonth": "V mesiaci %currentMonth nie sú žiadne udalosti",
"No events to show": "Žiadne udalosti na zobrazenie",
"No incidents for this day": "Pre tento deň nie sú evidované žiadne incidenty",
"No latency data available for this day": "Pre tento deň nie sú k dispozícii dáta latencie",
"No latency data available for this day": "Pre tento deň nie sú dostupné dáta latencie",
"No maintenances for this day": "Pre tento deň nie je naplánovaná žiadna údržba",
"No monitors affected": "Žiadne zasiahnuté monitory",
"No monitors available.": "Žiadne dostupné monitory.",
"No monitors affected": "Žiadne ovplyvnené monitory",
"No monitors available.": "Žiadne dostupné monitory",
"No ongoing maintenances": "Žiadna prebiehajúca údržba",
"No past maintenances": "Žiadna minula údržba",
"No past maintenances": "Žiadna minulá údržba",
"No Status Available": "Stav nie je k dispozícii",
"No upcoming maintenances": "Žiadna nadchádzajúca údržba",
"No Updates": "Žiadne aktualizácie",
"No updates yet": "Zatiaľ bez aktualizácií",
"No updates yet": "Zatiaľ žiadne aktualizácie",
"NO_DATA": "Žiadne dáta",
"Notifications": "Oznámenia",
"Notifications": "Upozornenia",
"One-time": "Jednorazovo",
"Ongoing": "Prebiehajúce",
"ONGOING": "PREBIEHAJÚCE",
"Ongoing Maintenances": "Prebiehajúce údržby",
"Partial Degraded Performance": "Čiastočne zhoršený výkon",
"Operational": "V prevádzke",
"Partial Degraded Performance": "Čiastočne obmedzený výkon",
"Partial System Outage": "Čiastočný výpadok systému",
"Past": "Minulé",
"Per-Minute Status": "Stav po minútach",
@@ -115,20 +121,20 @@
"Scheduled Windows": "Naplánované úlohy",
"Script": "Skript",
"Select Language": "Vyberte jazyk",
"Select latency metric to display": "Vyberte metriku latencie na zobrazenie",
"Select latency metric to display": "Vyberte metriku latencie",
"Select Range": "Vyberte rozsah",
"Sending...": "Odosielanie...",
"Standard": "Štandardný",
"Start Time": "Začiatok",
"Status": "Stav",
"Status Badge": "Odznak stavu",
"Status Embed": "Status na vloženie",
"Status Embed": "Stav na vloženie",
"Status history and latency trend": "História stavu a trend latencie",
"Subscribe": "Odober",
"Subscribe": "Prihlásiť sa na odber",
"Subscribe to Updates": "Odoberať aktualizácie",
"Theme": "Motív",
"There are no incidents or maintenances scheduled for this month.": "Na tento mesiac nie sú naplánované incidenty ani údržba.",
"There are no ongoing incidents or maintenance events.": "V tejto chvíli neprebiehajú žiadne incidenty ani údržba.",
"There are no incidents or maintenances scheduled for this month.": "Na tento mesiac nie sú naplánované žiadne incidenty ani údržby",
"There are no ongoing incidents or maintenance events.": "Momentálne neprebiehajú žiadne incidenty ani údržba",
"Timeline": "Časová os",
"Total Incidents": "Celkom incidentov",
"Total Maintenances": "Celkom údržieb",
@@ -146,6 +152,6 @@
"Verification failed": "Overenie zlyhalo",
"Verify": "Overiť",
"Verifying": "Overovanie",
"We sent a 6-digit code to": "Poslali sme 6-miestny kód na"
"We sent a 6-digit code to": "Odoslali sme 6-miestny kód na"
}
}
+11 -1
View File
@@ -17,8 +17,12 @@
"Day": "Gün",
"Day Uptime": "Gün Çalışma Süresi",
"Days": "Günler",
"Degraded": "Bozulmuş",
"DEGRADED": "DÜŞÜK PERFORMANS",
"Degraded Performance": "Düşük performans",
"Didn't receive the code? Resend": "Kodu almadınız mı? ",
"Down": "Çevrimdışı",
"DOWN": "ÇÖKME",
"Duration": "Süre",
"Email address": "E-posta adresi",
"Embed Monitor": "Gömülü Monitör",
@@ -38,9 +42,10 @@
"IDENTIFIED": "IDENTIFIED",
"iFrame": "iFrame",
"Impact": "Darbe",
"incident": "Olay",
"Incident Updates": "Olay Güncellemeleri",
"Incidents": "Olaylar",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Son Güncelleme",
"Latency": "Gecikme",
@@ -52,6 +57,8 @@
"Light": "Işık",
"Live Status": "Canlı Durum",
"Loading your preferences...": "Tercihleriniz yükleniyor...",
"maintenance": "Bakım",
"MAINTENANCE": "BAKIM",
"Maintenance Updates": "Bakım Güncellemeleri",
"Maintenances": "Bakımlar",
"Major System Outage": "Büyük sistem kesintisi",
@@ -80,6 +87,7 @@
"Notifications": "Bildirimler",
"One-time": "Bir kerelik",
"Ongoing": "devam ediyor",
"Operational": "Çalışıyor",
"Partial Degraded Performance": "Kısmi düşük performans",
"Partial System Outage": "Kısmi sistem kesintisi",
"Past": "Geçmiş",
@@ -113,7 +121,9 @@
"Total Maintenances": "Toplam Bakımlar",
"Under Maintenance": "Bakımda",
"Unknown impact": "Bilinmeyen etki",
"UP": "ÇALIŞIYOR",
"Upcoming": "Yaklaşan",
"Update Incident": "Olayı güncelle",
"Update Maintenance": "Bakımı güncelle",
"Updates": "Güncellemeler",
"Updates (%count)": "Güncellemeler (%count)",
+11 -1
View File
@@ -17,8 +17,12 @@
"Day": "Ngày",
"Day Uptime": "Thời gian hoạt động trong ngày",
"Days": "Ngày",
"Degraded": "Suy giảm",
"DEGRADED": "SUY GIẢM",
"Degraded Performance": "Hiệu suất suy giảm",
"Didn't receive the code? Resend": "Không nhận được mã? ",
"Down": "Ngừng hoạt động",
"DOWN": "NGỪNG HOẠT ĐỘNG",
"Duration": "Khoảng thời gian",
"Email address": "Địa chỉ email",
"Embed Monitor": "Màn hình nhúng",
@@ -38,9 +42,10 @@
"IDENTIFIED": "IDENTIFIED",
"iFrame": "iFrame",
"Impact": "Sự va chạm",
"incident": "Sự cố",
"Incident Updates": "Cập nhật sự cố",
"Incidents": "Sự cố",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Cập nhật lần cuối",
"Latency": "Độ trễ",
@@ -52,6 +57,8 @@
"Light": "Ánh sáng",
"Live Status": "Trạng thái trực tiếp",
"Loading your preferences...": "Đang tải tùy chọn của bạn...",
"maintenance": "Bảo trì",
"MAINTENANCE": "BẢO TRÌ",
"Maintenance Updates": "Cập nhật bảo trì",
"Maintenances": "Bảo trì",
"Major System Outage": "Sự cố hệ thống nghiêm trọng",
@@ -80,6 +87,7 @@
"Notifications": "Thông báo",
"One-time": "Một lần",
"Ongoing": "Đang thực hiện",
"Operational": "Hoạt động",
"Partial Degraded Performance": "Hiệu suất suy giảm một phần",
"Partial System Outage": "Sự cố hệ thống một phần",
"Past": "Quá khứ",
@@ -113,7 +121,9 @@
"Total Maintenances": "Tổng số lần bảo trì",
"Under Maintenance": "Đang bảo trì",
"Unknown impact": "Tác động không xác định",
"UP": "HOẠT ĐỘNG",
"Upcoming": "Sắp tới",
"Update Incident": "Cập nhật sự cố",
"Update Maintenance": "Cập nhật bảo trì",
"Updates": "Cập nhật",
"Updates (%count)": "Cập nhật (%count)",
+11 -1
View File
@@ -17,8 +17,12 @@
"Day": "天",
"Day Uptime": "日间正常运行时间",
"Days": "天",
"Degraded": "降级",
"DEGRADED": "降级",
"Degraded Performance": "性能下降",
"Didn't receive the code? Resend": "没有收到代码?",
"Down": "宕机",
"DOWN": "故障",
"Duration": "期间",
"Email address": "电子邮件",
"Embed Monitor": "嵌入监视器",
@@ -38,9 +42,10 @@
"IDENTIFIED": "IDENTIFIED",
"iFrame": "框架",
"Impact": "影响",
"incident": "事件",
"Incident Updates": "事件更新",
"Incidents": "事件",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "最后更新",
"Latency": "延迟",
@@ -52,6 +57,8 @@
"Light": "光",
"Live Status": "实时状态",
"Loading your preferences...": "正在加载您的偏好设置...",
"maintenance": "维护",
"MAINTENANCE": "维护中",
"Maintenance Updates": "维护更新",
"Maintenances": "维护保养",
"Major System Outage": "重大系统故障",
@@ -80,6 +87,7 @@
"Notifications": "通知",
"One-time": "一度",
"Ongoing": "进行中",
"Operational": "正常运行",
"Partial Degraded Performance": "部分性能下降",
"Partial System Outage": "部分系统故障.",
"Past": "过去的",
@@ -113,7 +121,9 @@
"Total Maintenances": "全面维护",
"Under Maintenance": "维护中",
"Unknown impact": "未知影响",
"UP": "正常",
"Upcoming": "即将推出",
"Update Incident": "更新事件",
"Update Maintenance": "更新维护",
"Updates": "更新",
"Updates (%count)": "更新 (%count)",
+133
View File
@@ -0,0 +1,133 @@
{
"name": "繁體中文(台灣)",
"mappings": {
"%latency %metric latency": "%latency %metric 延遲",
"Affected Monitors (%count)": "受影響的監控項目 (%count)",
"All Systems Operational": "所有系統正常運作",
"Average Latency": "平均延遲",
"Avg Latency": "平均延遲",
"Back": "返回",
"Badges": "徽章",
"CANCELLED": "已取消",
"COMPLETED": "已完成",
"Continue": "繼續",
"Copied": "已複製",
"Current": "目前",
"Dark": "深色",
"Day": "天",
"Day Uptime": "當日正常運作時間",
"Days": "天",
"DEGRADED": "降級",
"Degraded Performance": "效能降低",
"Didn't receive the code? Resend": "沒有收到驗證碼?重新傳送",
"DOWN": "故障",
"Duration": "持續時間",
"Edit Monitor": "編輯監控",
"Email address": "電子郵件地址",
"Embed Monitor": "嵌入監控",
"Embed this monitor in your website or app": "將此監控嵌入您的網站或應用程式",
"End Time": "結束時間",
"Enter the verification code sent to your email.": "請輸入傳送至您電子郵件的驗證碼。",
"Events": "事件",
"Failed to load data": "載入資料失敗",
"Failed to load latency data": "載入延遲資料失敗",
"Failed to load status data for this day": "無法載入當日的狀態資料",
"Failed to send verification code": "傳送驗證碼失敗",
"Failed to update preference": "更新偏好設定失敗",
"Get badges for this monitor": "取得此監控的徽章",
"Get notified about incidents and scheduled maintenance.": "接收事件與排程維護的通知。",
"Get notified about incidents updates": "接收事件更新通知",
"Get notified about scheduled maintenance": "接收排程維護通知",
"IDENTIFIED": "已確認",
"iFrame": "iFrame",
"Impact": "影響",
"Incident Updates": "事件更新",
"Incidents": "事件",
"Included Monitors (%count)": "包含的監控項目 (%count)",
"INVESTIGATING": "調查中",
"Last Updated": "最後更新",
"Latency": "延遲",
"Latency Embed": "延遲嵌入",
"Latency Over Time": "延遲趨勢圖",
"Latency Trend": "延遲趨勢",
"Latest Latency": "最新延遲",
"Latest Status": "最新狀態",
"Light": "淺色",
"Live Status": "即時狀態",
"Loading your preferences...": "正在載入您的偏好設定……",
"MAINTENANCE": "維護中",
"Maintenance Updates": "維護更新",
"Maintenances": "維護作業",
"Major System Outage": "重大系統中斷",
"Manage Site": "管理網站",
"Manage your notification preferences.": "管理您的通知偏好設定。",
"Max Latency": "最大延遲",
"Maximum Latency": "最大延遲",
"Min Latency": "最小延遲",
"Minimum Latency": "最小延遲",
"Minute-by-minute status data for this day": "當日逐分鐘狀態資料",
"MONITORING": "監控中",
"Network error. Please try again.": "網路錯誤,請重試。",
"No Events in %currentMonth": "%currentMonth 沒有事件",
"No events to show": "沒有可顯示的事件",
"No incidents for this day": "當日無事件",
"No latency data available for this day": "當日無延遲資料",
"No maintenances for this day": "當日無維護作業",
"No monitors affected": "沒有受影響的監控項目",
"No monitors available.": "沒有可用的監控項目。",
"No ongoing maintenances": "沒有進行中的維護作業",
"No past maintenances": "沒有過去的維護作業",
"No Status Available": "無可用狀態",
"No upcoming maintenances": "沒有即將進行的維護作業",
"No Updates": "沒有更新",
"No updates yet": "尚無更新",
"Notifications": "通知",
"One-time": "單次",
"Ongoing": "進行中",
"Partial Degraded Performance": "部分效能降低",
"Partial System Outage": "部分系統中斷",
"Past": "過去",
"Per-Minute Status": "逐分鐘狀態",
"Pinging": "偵測中",
"Please enter a valid email address": "請輸入有效的電子郵件地址",
"Please enter the 6-digit verification code": "請輸入 6 位數驗證碼",
"Read less": "收合",
"Read more": "展開",
"READY": "就緒",
"Recurring": "週期性",
"RESOLVED": "已解決",
"SCHEDULED": "已排程",
"Scheduled Events (%count)": "排程事件 (%count)",
"Script": "程式碼",
"Select Language": "選擇語言",
"Select latency metric to display": "選擇要顯示的延遲指標",
"Select Range": "選擇範圍",
"Sending...": "傳送中……",
"Standard": "標準",
"Start Time": "開始時間",
"Status": "狀態",
"Status Badge": "狀態徽章",
"Status Embed": "狀態嵌入",
"Status history and latency trend": "狀態歷程與延遲趨勢",
"Subscribe": "訂閱",
"Subscribe to Updates": "訂閱更新",
"There are no incidents or maintenances scheduled for this month.": "本月沒有排程的事件或維護作業。",
"There are no ongoing incidents or maintenance events.": "目前沒有進行中的事件或維護作業。",
"Total Incidents": "事件總數",
"Total Maintenances": "維護總數",
"Under Maintenance": "維護中",
"Unknown impact": "影響不明",
"UP": "正常",
"Upcoming": "即將進行",
"Update Incident": "更新事件",
"Update Maintenance": "更新維護",
"Updates": "更新",
"Updates (%count)": "更新 (%count)",
"Uptime": "正常運作時間",
"Uptime Badge": "正常運作時間徽章",
"Verification failed": "驗證失敗",
"Verify": "驗證",
"Verifying": "驗證中",
"We sent a 6-digit code to": "我們已傳送 6 位數驗證碼至"
}
}
+2
View File
@@ -33,6 +33,8 @@ export default async function get(_req: APIServerRequest): Promise<Response> {
const pages: PageNavItem[] = orderedPages.map((p) => ({
page_title: p.page_title,
page_path: p.page_path,
page_header: p.page_header,
page_logo: p.page_logo,
}));
return json(pages);
}
@@ -3,7 +3,7 @@ import { GetMinuteStartNowTimestampUTC, BeginningOfMinute, BeginningOfDay } from
import { GetPageByPathWithMonitors, GetLatestMonitoringDataAllActive } from "./controller.js";
import { GetMonitorsParsed } from "./monitorsController.js";
import { GetStatusSummary, GetStatusBgColor } from "../../clientTools";
import { formatDistanceStrict } from "date-fns";
import type {
IncidentRecord,
IncidentCommentRecord,
@@ -32,7 +32,8 @@ export interface NotificationEvent {
eventTitle: string;
eventDate: string;
eventType: string;
eventDuration: string;
eventStartDateTime: number;
eventEndDateTime: number | null;
eventStatus: string;
}
@@ -150,15 +151,21 @@ export const GetAffectedMonitorsByIncidentId = async (
export interface PageNavItem {
page_title: string;
page_path: string;
page_header: string;
page_logo: string | null;
}
export interface PageDashboardData {
pageStatus: { statusSummary: string; statusClass: string };
ongoingIncidents: IncidentForMonitorListWithComments[];
ongoingMaintenances: MaintenanceEventsMonitorList[];
upcomingMaintenances: MaintenanceEventsMonitorList[];
monitorTags: string[];
monitorGroupMembersByTag: Record<string, string[]>;
pageDetails: PageRecordTyped;
socialPagePreviewImage?: string;
metaPageTitle?: string;
metaPageDescription?: string;
}
const BuildPageStatus = (latestData: Array<{ status?: string | null; latency?: number | null }>, nowTs: number) => {
@@ -218,11 +225,6 @@ export const BuildNotificationPayload = (
): NotificationPayload => {
const notifications: Array<NotificationEvent & { sortTs: number }> = [];
const durationFromRange = (startTs: number, endTs: number | null): string => {
const effectiveEnd = Math.max(startTs, endTs ?? nowTs);
return formatDistanceStrict(new Date(startTs * 1000), new Date(effectiveEnd * 1000));
};
for (const incident of ongoingIncidents) {
const ts = incident.start_date_time;
notifications.push({
@@ -231,7 +233,8 @@ export const BuildNotificationPayload = (
eventTitle: incident.title,
eventDate: new Date(ts * 1000).toISOString(),
eventType: "incident",
eventDuration: durationFromRange(incident.start_date_time, incident.end_date_time),
eventStartDateTime: incident.start_date_time,
eventEndDateTime: incident.end_date_time,
eventStatus: incident.state,
});
}
@@ -244,7 +247,8 @@ export const BuildNotificationPayload = (
eventTitle: incident.title,
eventDate: new Date(ts * 1000).toISOString(),
eventType: "incident",
eventDuration: durationFromRange(incident.start_date_time, incident.end_date_time),
eventStartDateTime: incident.start_date_time,
eventEndDateTime: incident.end_date_time,
eventStatus: incident.state,
});
}
@@ -257,7 +261,8 @@ export const BuildNotificationPayload = (
eventTitle: maintenance.title,
eventDate: new Date(ts * 1000).toISOString(),
eventType: "maintenance",
eventDuration: durationFromRange(maintenance.start_date_time, maintenance.end_date_time),
eventStartDateTime: maintenance.start_date_time,
eventEndDateTime: maintenance.end_date_time,
eventStatus: GC.ONGOING,
});
}
@@ -270,7 +275,8 @@ export const BuildNotificationPayload = (
eventTitle: maintenance.title,
eventDate: new Date(ts * 1000).toISOString(),
eventType: "maintenance",
eventDuration: durationFromRange(maintenance.start_date_time, maintenance.end_date_time),
eventStartDateTime: maintenance.start_date_time,
eventEndDateTime: maintenance.end_date_time,
eventStatus: GC.SCHEDULED,
});
}
@@ -283,7 +289,8 @@ export const BuildNotificationPayload = (
eventTitle: maintenance.title,
eventDate: new Date(ts * 1000).toISOString(),
eventType: "maintenance",
eventDuration: durationFromRange(maintenance.start_date_time, maintenance.end_date_time),
eventStartDateTime: maintenance.start_date_time,
eventEndDateTime: maintenance.end_date_time,
eventStatus: GC.COMPLETED,
});
}
@@ -339,19 +346,39 @@ export const GetPageDashboardData = async (
updated_at: pageDetails.updated_at,
};
let socialPagePreviewImage: string | undefined = layoutData.socialPreviewImage;
let metaPageTitle: string | undefined = layoutData.metaSiteTitle;
let metaPageDescription: string | undefined = layoutData.metaSiteDescription;
if (!!pageDetails.page_settings_json) {
try {
const pageSettings = JSON.parse(pageDetails.page_settings_json);
if (pageSettings) {
socialPagePreviewImage = pageSettings.socialPagePreviewImage || layoutData.socialPreviewImage;
metaPageTitle = pageSettings.metaPageTitle || layoutData.metaSiteTitle;
metaPageDescription = pageSettings.metaPageDescription || layoutData.metaSiteDescription;
}
} catch (e) {
// Ignore JSON parsing errors and fallback to layout data or defaults
}
}
if (monitorTags.length === 0) {
return {
pageStatus: BuildPageStatus([], nowTs),
ongoingIncidents: [],
ongoingMaintenances: [],
upcomingMaintenances: [],
monitorTags,
monitorGroupMembersByTag: {},
pageDetails: pageDetailsTyped,
socialPagePreviewImage,
metaPageTitle,
metaPageDescription,
};
}
const eventSettings = layoutData.eventDisplaySettings;
// Fetch all dashboard data in parallel (respecting feature toggles)
const [latestData, parsedMonitors, ongoingIncidents, ongoingMaintenances] = await Promise.all([
const [latestData, parsedMonitors, ongoingIncidents, ongoingMaintenances, upcomingMaintenances] = await Promise.all([
GetLatestMonitoringDataAllActive(monitorTags),
GetMonitorsParsed({ tags: monitorTags, status: "ACTIVE", is_hidden: "NO" }),
eventSettings.incidents.enabled && eventSettings.incidents.ongoing.show
@@ -360,6 +387,13 @@ export const GetPageDashboardData = async (
eventSettings.maintenances.enabled && eventSettings.maintenances.ongoing.show
? GetOngoingMaintenances(monitorTags, nowTs)
: Promise.resolve([] as MaintenanceEventsMonitorList[]),
eventSettings.maintenances.enabled && eventSettings.maintenances.upcoming.show
? GetUpcomingMaintenanceEventsForMonitorList(
monitorTags,
eventSettings.maintenances.upcoming.maxCount,
eventSettings.maintenances.upcoming.daysInFuture,
)
: Promise.resolve([] as MaintenanceEventsMonitorList[]),
]);
const pageStatus = BuildPageStatus(latestData, nowTs);
@@ -378,8 +412,12 @@ export const GetPageDashboardData = async (
pageStatus,
ongoingIncidents,
ongoingMaintenances,
upcomingMaintenances,
monitorTags,
monitorGroupMembersByTag,
pageDetails: pageDetailsTyped,
socialPagePreviewImage,
metaPageTitle,
metaPageDescription,
};
};
@@ -526,3 +526,30 @@ export const ParseIncidentToAPIResp = async (
return resp;
};
export const DeleteIncident = async (incident_id: number): Promise<{ success: boolean }> => {
const incident = await db.getIncidentById(incident_id);
if (!incident) {
throw new Error(`Incident with id ${incident_id} does not exist`);
}
// Set incident_id to null in monitor_alerts_v2
const alerts = await db.getAlertsByIncidentId(incident_id);
for (const alert of alerts) {
await db.updateMonitorAlertV2(alert.id, { incident_id: null });
}
// Delete incident monitors
const monitors = await db.getIncidentMonitorsByIncidentID(incident_id);
for (const monitor of monitors) {
await db.removeIncidentMonitor(incident_id, monitor.monitor_tag);
}
// Delete incident comments permanently
await db.deleteIncidentCommentsByIncidentID(incident_id);
// Delete the incident
await db.deleteIncident(incident_id);
return { success: true };
};
@@ -10,7 +10,7 @@ import {
IsEmailSetup,
IsSetupComplete,
} from "./controller.js";
import type { EventDisplaySettings, GlobalPageVisibilitySettings } from "$lib/types/site.js";
import type { EventDisplaySettings, GlobalPageVisibilitySettings, SiteDateTimeFormat } from "$lib/types/site.js";
export interface LayoutServerData {
isMobile: boolean;
@@ -70,6 +70,9 @@ export interface LayoutServerData {
socialPreviewImage?: string;
customCSS?: string;
globalPageVisibilitySettings: GlobalPageVisibilitySettings;
dateAndTimeFormat: SiteDateTimeFormat;
metaSiteTitle?: string;
metaSiteDescription?: string;
}
export async function GetLayoutServerData(cookies: Cookies, request: Request): Promise<LayoutServerData> {
@@ -137,5 +140,8 @@ export async function GetLayoutServerData(cookies: Cookies, request: Request): P
socialPreviewImage: siteData.socialPreviewImage,
customCSS: siteData.customCSS,
globalPageVisibilitySettings: siteData.globalPageVisibilitySettings || seedSiteData.globalPageVisibilitySettings,
dateAndTimeFormat: siteData.dateAndTimeFormat || seedSiteData.dateAndTimeFormat,
metaSiteTitle: siteData.metaSiteTitle,
metaSiteDescription: siteData.metaSiteDescription,
};
}
@@ -16,6 +16,7 @@ import { maintenanceToVariables, siteDataToVariables } from "../notification/not
import { GetAllSiteData } from "./controller.js";
import subscriberQueue from "../queues/subscriberQueue.js";
import GC from "../../global-constants";
import seedSiteData from "../db/seedSiteData.js";
// ============ Input Interfaces ============
@@ -78,6 +79,7 @@ export interface MaintenanceWithEvents extends MaintenanceWithMonitors {
export function determineEventStatus(
eventStartTimestamp: number,
eventEndTimestamp: number,
reminderBufferSeconds: number = 3600,
): "SCHEDULED" | "READY" | "ONGOING" | "COMPLETED" | "CANCELLED" {
const nowTimestamp = Math.floor(Date.now() / 1000);
@@ -87,38 +89,90 @@ export function determineEventStatus(
if (nowTimestamp >= eventStartTimestamp) {
return "ONGOING";
}
// 60 minutes = 3600 seconds
if (eventStartTimestamp - nowTimestamp <= 3600) {
if (eventStartTimestamp - nowTimestamp <= reminderBufferSeconds) {
return "READY";
}
return "SCHEDULED";
}
// ============ Helper to create a maintenance event with notification ============
export const CreateMaintenanceEventWithNotification = async (
maintenance_id: number,
start_date_time: number,
end_date_time: number,
title: string,
description: string | null,
): Promise<MaintenanceEventRecord> => {
const siteData = await GetAllSiteData();
const notificationSettings =
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
const reminderBufferSeconds = notificationSettings.reminder_buffer_hours * 3600;
const event = await db.createMaintenanceEvent({
maintenance_id,
start_date_time,
end_date_time,
status: determineEventStatus(start_date_time, end_date_time, reminderBufferSeconds),
});
try {
if (notificationSettings.event_types.created) {
const siteVars = siteDataToVariables(siteData);
const siteUrl = siteVars.site_url;
const monitors = await db.getMonitorsByMaintenanceId(maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const eventDetailed: MaintenanceEventRecordDetailed = {
id: event.id,
maintenance_id,
start_date_time,
end_date_time,
status: event.status as MaintenanceEventRecordDetailed["status"],
created_at: new Date(),
updated_at: new Date(),
title,
description,
};
const update = maintenanceToVariables(
eventDetailed,
monitorNames,
"**has been created**",
"created",
"Maintenance Created",
siteUrl,
);
await subscriberQueue.push(update);
}
} catch (err) {
console.error(`Error sending created notification for maintenance event ${event.id}:`, err);
}
return event;
};
// ============ Helper to generate upcoming events from RRULE ============
/**
* Generate maintenance events for the next N days based on the RRULE
* Generate maintenance events based on the RRULE
* @param maintenance_id - The maintenance record ID
* @param start_date_time - Unix timestamp for the DTSTART
* @param rrule - The RRULE string (e.g., FREQ=WEEKLY;BYDAY=SU)
* @param duration_seconds - Duration of each maintenance window
* @param daysAhead - Number of days to look ahead (default 7)
* @param count - Maximum number of events to create (default 1)
*/
export const GenerateMaintenanceEvents = async (
maintenance_id: number,
start_date_time: number,
rrule: string,
duration_seconds: number,
daysAhead: number = 7,
count: number = 1,
): Promise<MaintenanceEventRecord[]> => {
const createdEvents: MaintenanceEventRecord[] = [];
// Convert start timestamp to Date (UTC)
const dtstart = new Date(start_date_time * 1000);
// Define the window to generate events
const now = new Date();
const windowEnd = addDays(now, daysAhead);
try {
// Build the full RRULE string with DTSTART
@@ -127,22 +181,30 @@ export const GenerateMaintenanceEvents = async (
// Parse the RRULE
const rule = rrulestr(fullRrule);
// Get occurrences between now and window end
// For one-time (COUNT=1), we use dtstart as the reference
let occurrences: Date[];
// Get occurrences based on count
let occurrences: Date[] = [];
if (rrule.includes("COUNT=1")) {
// One-time maintenance: only create event if start_date_time is in the future or within window
if (dtstart >= now || (dtstart <= windowEnd && dtstart >= addDays(now, -1))) {
// One-time maintenance: only create event if start_date_time is recent or in the future
if (dtstart >= now || dtstart >= addDays(now, -1)) {
occurrences = [dtstart];
} else {
occurrences = [];
}
} else {
// Recurring: get all occurrences in the window
occurrences = rule.between(now, windowEnd, true);
// Recurring: get the next `count` occurrences from now
let searchFrom = now;
for (let i = 0; i < count; i++) {
const next = rule.after(searchFrom, i === 0);
if (!next) break;
occurrences.push(next);
searchFrom = new Date(next.getTime() + 1000);
}
}
// Fetch maintenance info for notifications
const maintenance = await db.getMaintenanceById(maintenance_id);
const maintenanceTitle = maintenance?.title || "";
const maintenanceDescription = maintenance?.description || null;
// Create events for each occurrence
for (const occurrence of occurrences) {
const eventStart = Math.floor(occurrence.getTime() / 1000);
@@ -153,12 +215,13 @@ export const GenerateMaintenanceEvents = async (
const alreadyExists = existing.some((e) => e.start_date_time === eventStart);
if (!alreadyExists) {
const event = await db.createMaintenanceEvent({
const event = await CreateMaintenanceEventWithNotification(
maintenance_id,
start_date_time: eventStart,
end_date_time: eventEnd,
status: determineEventStatus(eventStart, eventEnd),
});
eventStart,
eventEnd,
maintenanceTitle,
maintenanceDescription,
);
createdEvents.push(event);
}
}
@@ -202,8 +265,8 @@ export const CreateMaintenance = async (data: CreateMaintenanceInput): Promise<{
await db.addMonitorsToMaintenanceWithStatus(maintenance.id, data.monitors);
}
// Generate initial events for the next 7 days
await GenerateMaintenanceEvents(maintenance.id, data.start_date_time, data.rrule, data.duration_seconds, 7);
// Generate initial events
await GenerateMaintenanceEvents(maintenance.id, data.start_date_time, data.rrule, data.duration_seconds, 1);
return {
maintenance_id: maintenance.id,
@@ -336,7 +399,7 @@ export const UpdateMaintenance = async (id: number, data: UpdateMaintenanceInput
}
}
// Regenerate the event
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 7);
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 1);
} else {
// For recurring maintenances: delete future SCHEDULED events and regenerate
for (const event of events) {
@@ -345,8 +408,8 @@ export const UpdateMaintenance = async (id: number, data: UpdateMaintenanceInput
await db.deleteMaintenanceEvent(event.id);
}
}
// Regenerate events for the next 7 days
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 7);
// Regenerate events
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 1);
}
}
}
@@ -377,11 +440,16 @@ export const CreateMaintenanceEvent = async (data: CreateMaintenanceEventInput):
throw new Error("End date/time must be after start date/time");
}
const siteData = await GetAllSiteData();
const notificationSettings =
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
const reminderBufferSeconds = notificationSettings.reminder_buffer_hours * 3600;
const event = await db.createMaintenanceEvent({
maintenance_id: data.maintenance_id,
start_date_time: data.start_date_time,
end_date_time: data.end_date_time,
status: determineEventStatus(data.start_date_time, data.end_date_time),
status: determineEventStatus(data.start_date_time, data.end_date_time, reminderBufferSeconds),
});
return event;
@@ -534,20 +602,23 @@ export const formatDurationSeconds = (seconds: number): string => {
/**
* Update maintenance event statuses based on current time:
* 1. SCHEDULED events starting within 60 minutes → READY
* 1. SCHEDULED events starting within the reminder buffer → READY
* 2. READY events where current time is within start/end → ONGOING
* 3. ONGOING events where end_date_time has passed → COMPLETED
*/
export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
const currentTimestamp = GetMinuteStartNowTimestampUTC();
const sixtyMinutesInSeconds = 60 * 60;
const siteData = await GetAllSiteData();
const siteVars = siteDataToVariables(siteData);
const siteUrl = siteVars.site_url;
//get global maintenance notification settings
const notificationSettings =
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
const reminderBufferSeconds = notificationSettings.reminder_buffer_hours * 3600;
try {
// 1. Mark SCHEDULED events starting within 60 minutes as READY
const scheduledEvents = await db.getScheduledEventsStartingSoon(currentTimestamp, sixtyMinutesInSeconds);
// 1. Mark SCHEDULED events starting within the reminder buffer as READY
const scheduledEvents = await db.getScheduledEventsStartingSoon(currentTimestamp, reminderBufferSeconds);
for (const event of scheduledEvents) {
await db.updateMaintenanceEventStatus(event.id, GC.READY);
console.log(`Maintenance event ${event.id} marked as READY (starts at ${event.start_date_time})`);
@@ -557,15 +628,17 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
new Date(event.start_date_time * 1000),
new Date(currentTimestamp * 1000),
);
const update = maintenanceToVariables(
event,
monitorNames,
`**is starting in ${timeUntilStart}**`,
"starting_soon",
"Maintenance Starting Soon",
siteUrl,
);
await subscriberQueue.push(update);
if (notificationSettings.event_types.reminder) {
const update = maintenanceToVariables(
event,
monitorNames,
`**is starting in ${timeUntilStart}**`,
"starting_soon",
"Maintenance Starting Soon",
siteUrl,
);
await subscriberQueue.push(update);
}
}
// 2. Catch-up: SCHEDULED events that missed the READY window and already started → ONGOING
@@ -575,15 +648,18 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
console.log(`Maintenance event ${event.id} marked as ONGOING (catch-up from SCHEDULED)`);
const monitors = await db.getMonitorsByMaintenanceId(event.maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const update = maintenanceToVariables(
event,
monitorNames,
"**is now in progress**",
"ongoing",
"Maintenance In Progress",
siteUrl,
);
await subscriberQueue.push(update);
if (notificationSettings.event_types.started) {
const update = maintenanceToVariables(
event,
monitorNames,
"**is now in progress**",
"ongoing",
"Maintenance In Progress",
siteUrl,
);
await subscriberQueue.push(update);
}
}
// 3. Mark READY events that are now in progress as ONGOING
@@ -593,15 +669,18 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
console.log(`Maintenance event ${event.id} marked as ONGOING`);
const monitors = await db.getMonitorsByMaintenanceId(event.maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const update = maintenanceToVariables(
event,
monitorNames,
"**is now in progress**",
"ongoing",
"Maintenance In Progress",
siteUrl,
);
await subscriberQueue.push(update);
if (notificationSettings.event_types.started) {
const update = maintenanceToVariables(
event,
monitorNames,
"**is now in progress**",
"ongoing",
"Maintenance In Progress",
siteUrl,
);
await subscriberQueue.push(update);
}
}
// 4. Mark ONGOING events that have ended as COMPLETED
@@ -611,15 +690,18 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
console.log(`Maintenance event ${event.id} marked as COMPLETED`);
const monitors = await db.getMonitorsByMaintenanceId(event.maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const update = maintenanceToVariables(
event,
monitorNames,
"**has been completed**",
"completed",
"Maintenance Completed",
siteUrl,
);
await subscriberQueue.push(update);
if (notificationSettings.event_types.ended) {
const update = maintenanceToVariables(
event,
monitorNames,
"**has been completed**",
"completed",
"Maintenance Completed",
siteUrl,
);
await subscriberQueue.push(update);
}
}
} catch (error) {
console.error("Error updating maintenance event statuses:", error);
@@ -127,19 +127,20 @@ export async function CreateMonitorAlertConfig(
// Validate input
validateMonitorAlertConfigInput(data);
if (!data.monitor_tag) {
throw new Error("monitor_tag is required");
if (!data.monitor_tags || data.monitor_tags.length === 0) {
throw new Error("At least one monitor is required");
}
// Check if monitor exists
const monitor = await db.getMonitorByTag(data.monitor_tag);
if (!monitor) {
throw new Error(`Monitor with tag '${data.monitor_tag}' not found`);
// Check if all monitors exist
for (const tag of data.monitor_tags) {
const monitor = await db.getMonitorByTag(tag);
if (!monitor) {
throw new Error(`Monitor with tag '${tag}' not found`);
}
}
// Prepare insert data
const insertData: MonitorAlertConfigInsert = {
monitor_tag: data.monitor_tag,
alert_for: data.alert_for,
alert_value: data.alert_value,
failure_threshold: data.failure_threshold,
@@ -153,6 +154,9 @@ export async function CreateMonitorAlertConfig(
// Insert alert config
const id = await db.insertMonitorAlertConfig(insertData);
// Add monitors to junction table
await db.addMonitorsToAlertConfig(id, data.monitor_tags);
// Add triggers if provided
if (data.trigger_ids && data.trigger_ids.length > 0) {
await db.addTriggersToMonitorAlertConfig(id, data.trigger_ids);
@@ -194,6 +198,19 @@ export async function UpdateMonitorAlertConfig(
validateAlertValue(alertFor, data.alert_value);
}
// Validate monitor_tags if provided
if (data.monitor_tags !== undefined) {
if (data.monitor_tags.length === 0) {
throw new Error("At least one monitor is required");
}
for (const tag of data.monitor_tags) {
const monitor = await db.getMonitorByTag(tag);
if (!monitor) {
throw new Error(`Monitor with tag '${tag}' not found`);
}
}
}
// Prepare update data
const updateData: MonitorAlertConfigUpdate = {};
if (data.alert_for !== undefined) updateData.alert_for = data.alert_for;
@@ -210,6 +227,11 @@ export async function UpdateMonitorAlertConfig(
await db.updateMonitorAlertConfig(data.id, updateData);
}
// Update monitors if provided
if (data.monitor_tags !== undefined) {
await db.replaceAlertConfigMonitors(data.id, data.monitor_tags);
}
// Update triggers if provided
if (data.trigger_ids !== undefined) {
await db.replaceMonitorAlertConfigTriggers(data.id, data.trigger_ids);
@@ -420,6 +442,7 @@ function validateAlertStatus(value: string): asserts value is MonitorAlertStatus
*/
export async function CreateMonitorAlertV2(
configId: number,
monitorTag?: string | null,
incidentId?: number | null,
): Promise<MonitorAlertV2Record> {
// Check if config exists
@@ -438,6 +461,7 @@ export async function CreateMonitorAlertV2(
const insertData: MonitorAlertV2Insert = {
config_id: configId,
monitor_tag: monitorTag || null,
incident_id: incidentId || null,
alert_status: "TRIGGERED",
};
@@ -26,6 +26,7 @@ import GC, { getBadgeStyle, type BadgeStyle } from "../../global-constants.js";
import { makeBadge } from "badge-maker";
import { ErrorSvg } from "../../anywhere.js";
import { GetLastMonitoringValue, SetLastHeartbeat, DeleteMonitorCaches } from "../cache/setGet.js";
import { translate, isLocaleAvailable } from "../i18n.js";
import type { HeartbeatMonitor, GroupMonitorTypeData } from "../types/monitor.js";
interface GroupUpdateData {
@@ -398,9 +399,7 @@ async function removeTagFromGroupMonitors(tag: string): Promise<void> {
const weight = Math.round((1 / remaining.length) * 1000) / 1000;
for (let i = 0; i < remaining.length; i++) {
remaining[i].weight =
i === remaining.length - 1
? Math.round((1 - weight * (remaining.length - 1)) * 1000) / 1000
: weight;
i === remaining.length - 1 ? Math.round((1 - weight * (remaining.length - 1)) * 1000) / 1000 : weight;
}
}
@@ -506,6 +505,7 @@ export interface BadgeParams {
color?: string | null;
style?: string | null;
metric?: string | null;
locale?: string | null;
}
function formatDuration(rangeInSeconds: number): string {
@@ -559,7 +559,31 @@ export const GetBadge = async (badgeType: BadgeType, params: BadgeParams): Promi
}
const status = (lastObj?.status as string) || GC.NO_DATA;
message = status;
// Resolve locale: validate against activated locales, fall back to configured default
const i18nData = await db.getSiteDataByKey("i18n");
let i18nConfig: { defaultLocale: string; locales: Array<{ code: string; selected: boolean }> } | null = null;
if (i18nData?.value) {
try {
i18nConfig = typeof i18nData.value === "string" ? JSON.parse(i18nData.value) : i18nData.value;
} catch {
i18nConfig = null;
}
}
const defaultLocale = i18nConfig?.defaultLocale || "en";
const activatedCodes = new Set(i18nConfig?.locales?.filter((l) => l.selected).map((l) => l.code) ?? ["en"]);
const requestedLocale = params.locale || defaultLocale;
const locale =
activatedCodes.has(requestedLocale) && isLocaleAvailable(requestedLocale) ? requestedLocale : defaultLocale;
const statusLocaleKey: Record<string, string> = {
[GC.UP]: "Operational",
[GC.DEGRADED]: "Degraded",
[GC.DOWN]: "Down",
[GC.MAINTENANCE]: "Under Maintenance",
[GC.NO_DATA]: "No Status Available",
};
message = translate(locale, statusLocaleKey[status] || status, defaultLocale);
// Use status-specific color if no custom color provided
if (!params.color) {
@@ -107,6 +107,7 @@ export async function AddMonitorToPage(
page_id: number,
monitor_tag: string,
monitor_settings_json?: string | null,
position?: number,
): Promise<void> {
// Check if page exists
const page = await db.getPageById(page_id);
@@ -120,13 +121,38 @@ export async function AddMonitorToPage(
throw new Error(`Monitor "${monitor_tag}" already exists on this page`);
}
// If no position specified, append at end
let finalPosition = position;
if (finalPosition === undefined) {
const existing = await db.getPageMonitors(page_id);
finalPosition = existing.length > 0 ? Math.max(...existing.map((m) => m.position)) + 1 : 0;
}
await db.addMonitorToPage({
page_id,
monitor_tag,
monitor_settings_json: monitor_settings_json || null,
position: finalPosition,
});
}
/**
* Reorder monitors on a page
*/
export async function ReorderPageMonitors(page_id: number, monitor_tags: string[]): Promise<void> {
const page = await db.getPageById(page_id);
if (!page) {
throw new Error(`Page with id ${page_id} not found`);
}
const monitorPositions = monitor_tags.map((tag, index) => ({
monitor_tag: tag,
position: index,
}));
await db.updatePageMonitorPositions(page_id, monitorPositions);
}
/**
* Remove monitor from a page
*/
@@ -17,7 +17,10 @@ import type {
SiteNavItem,
SiteStatusColors,
SiteSubMenuOptions,
SiteDateTimeFormat,
SiteSubscriptionsSettings,
SitemapXMLConfig,
GlobalMaintenanceNotificationSettings,
} from "../../types/site.js";
export interface SiteDataTransformed {
@@ -60,6 +63,11 @@ export interface SiteDataTransformed {
customCSS?: string;
globalPageVisibilitySettings?: GlobalPageVisibilitySettings;
pageOrderingSettings?: PageOrderingSettings;
dateAndTimeFormat?: SiteDateTimeFormat;
metaSiteTitle?: string;
metaSiteDescription?: string;
sitemap?: SitemapXMLConfig;
globalMaintenanceNotificationSettings?: GlobalMaintenanceNotificationSettings;
}
export function InsertKeyValue(key: string, value: string): Promise<number[]> {
+27 -2
View File
@@ -43,12 +43,12 @@ export const siteDataKeys: SiteDataKey[] = [
},
{
key: "favicon",
isValid: (value) => typeof value === "string" && value.trim().length > 0,
isValid: (value) => typeof value === "string",
data_type: "string",
},
{
key: "logo",
isValid: (value) => typeof value === "string" && value.trim().length > 0,
isValid: (value) => typeof value === "string",
data_type: "string",
},
{
@@ -271,4 +271,29 @@ export const siteDataKeys: SiteDataKey[] = [
isValid: IsValidJSONString,
data_type: "object",
},
{
key: "dateAndTimeFormat",
isValid: IsValidJSONString,
data_type: "object",
},
{
key: "metaSiteTitle",
isValid: (value) => typeof value === "string",
data_type: "string",
},
{
key: "metaSiteDescription",
isValid: (value) => typeof value === "string",
data_type: "string",
},
{
key: "sitemap",
isValid: IsValidJSONString,
data_type: "object",
},
{
key: "globalMaintenanceNotificationSettings",
isValid: IsValidJSONString,
data_type: "object",
},
];
+18
View File
@@ -187,6 +187,7 @@ class DbImpl {
updateIncidentCommentByID!: IncidentsRepository["updateIncidentCommentByID"];
updateIncidentCommentStatusByID!: IncidentsRepository["updateIncidentCommentStatusByID"];
getIncidentCommentByID!: IncidentsRepository["getIncidentCommentByID"];
deleteIncidentCommentsByIncidentID!: IncidentsRepository["deleteIncidentCommentsByIncidentID"];
// ============ Images ============
insertImage!: ImagesRepository["insertImage"];
@@ -212,6 +213,7 @@ class DbImpl {
monitorExistsOnPage!: PagesRepository["monitorExistsOnPage"];
deletePageMonitorsByTag!: PagesRepository["deletePageMonitorsByTag"];
deletePageMonitorsByPageId!: PagesRepository["deletePageMonitorsByPageId"];
updatePageMonitorPositions!: PagesRepository["updatePageMonitorPositions"];
// ============ Maintenances ============
createMaintenance!: MaintenancesRepository["createMaintenance"];
@@ -290,6 +292,12 @@ class DbImpl {
isTriggerUsedInMonitorAlertConfig!: MonitorAlertConfigRepository["isTriggerUsedInMonitorAlertConfig"];
getMonitorAlertConfigsByTriggerId!: MonitorAlertConfigRepository["getMonitorAlertConfigsByTriggerId"];
// ============ Monitor Alert Config Monitors ============
addMonitorsToAlertConfig!: MonitorAlertConfigRepository["addMonitorsToAlertConfig"];
removeAllMonitorsFromAlertConfig!: MonitorAlertConfigRepository["removeAllMonitorsFromAlertConfig"];
replaceAlertConfigMonitors!: MonitorAlertConfigRepository["replaceAlertConfigMonitors"];
getAlertConfigMonitorTags!: MonitorAlertConfigRepository["getAlertConfigMonitorTags"];
// ============ Monitor Alerts V2 ============
insertMonitorAlertV2!: MonitorAlertConfigRepository["insertMonitorAlertV2"];
updateMonitorAlertV2!: MonitorAlertConfigRepository["updateMonitorAlertV2"];
@@ -529,6 +537,7 @@ class DbImpl {
this.updateIncidentCommentByID = this.incidents.updateIncidentCommentByID.bind(this.incidents);
this.updateIncidentCommentStatusByID = this.incidents.updateIncidentCommentStatusByID.bind(this.incidents);
this.getIncidentCommentByID = this.incidents.getIncidentCommentByID.bind(this.incidents);
this.deleteIncidentCommentsByIncidentID = this.incidents.deleteIncidentCommentsByIncidentID.bind(this.incidents);
}
private bindImagesMethods(): void {
@@ -554,6 +563,7 @@ class DbImpl {
this.monitorExistsOnPage = this.pages.monitorExistsOnPage.bind(this.pages);
this.deletePageMonitorsByTag = this.pages.deletePageMonitorsByTag.bind(this.pages);
this.deletePageMonitorsByPageId = this.pages.deletePageMonitorsByPageId.bind(this.pages);
this.updatePageMonitorPositions = this.pages.updatePageMonitorPositions.bind(this.pages);
}
private bindMaintenancesMethods(): void {
@@ -688,6 +698,14 @@ class DbImpl {
this.monitorAlertConfig,
);
// Monitor Alert Config Monitors
this.addMonitorsToAlertConfig = this.monitorAlertConfig.addMonitorsToAlertConfig.bind(this.monitorAlertConfig);
this.removeAllMonitorsFromAlertConfig = this.monitorAlertConfig.removeAllMonitorsFromAlertConfig.bind(
this.monitorAlertConfig,
);
this.replaceAlertConfigMonitors = this.monitorAlertConfig.replaceAlertConfigMonitors.bind(this.monitorAlertConfig);
this.getAlertConfigMonitorTags = this.monitorAlertConfig.getAlertConfigMonitorTags.bind(this.monitorAlertConfig);
// Monitor Alerts V2
this.insertMonitorAlertV2 = this.monitorAlertConfig.insertMonitorAlertV2.bind(this.monitorAlertConfig);
this.updateMonitorAlertV2 = this.monitorAlertConfig.updateMonitorAlertV2.bind(this.monitorAlertConfig);
+1
View File
@@ -9,6 +9,7 @@ export interface MonitorFilter {
tag?: string;
is_hidden?: string;
tags?: string[];
search?: string;
}
export interface TriggerFilter {
@@ -818,6 +818,10 @@ export class IncidentsRepository extends BaseRepository {
return await this.knex("incident_comments").where({ id }).first();
}
async deleteIncidentCommentsByIncidentID(incident_id: number): Promise<number> {
return await this.knex("incident_comments").where({ incident_id }).del();
}
/**
* Get incidents within a date range for the events page
* Returns incidents that started within the given date range
@@ -7,6 +7,8 @@ import type {
MonitorAlertConfigTriggerRecord,
MonitorAlertConfigTriggerInsert,
MonitorAlertConfigWithTriggers,
MonitorAlertConfigMonitorRecord,
MonitorAlertConfigMonitorInsert,
TriggerRecord,
MonitorAlertV2Record,
MonitorAlertV2Insert,
@@ -29,7 +31,7 @@ export class MonitorAlertConfigRepository extends BaseRepository {
async insertMonitorAlertConfig(data: MonitorAlertConfigInsert): Promise<number> {
const dbType = GetDbType();
const insertData = {
monitor_tag: data.monitor_tag,
monitor_tag: data.monitor_tag || null,
alert_for: data.alert_for,
alert_value: data.alert_value,
failure_threshold: data.failure_threshold,
@@ -88,29 +90,35 @@ export class MonitorAlertConfigRepository extends BaseRepository {
* Get monitor alert configs with optional filtering
*/
async getMonitorAlertConfigs(filter: MonitorAlertConfigFilter): Promise<MonitorAlertConfigRecord[]> {
let query = this.knex("monitor_alerts_config").whereRaw("1=1");
let query = this.knex("monitor_alerts_config as mac").select("mac.*").whereRaw("1=1");
if (filter.id !== undefined) {
query = query.andWhere("id", filter.id);
query = query.andWhere("mac.id", filter.id);
}
if (filter.monitor_tag !== undefined) {
query = query.andWhere("monitor_tag", filter.monitor_tag);
query = query
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.andWhere("macm.monitor_tag", filter.monitor_tag);
}
if (filter.alert_for !== undefined) {
query = query.andWhere("alert_for", filter.alert_for);
query = query.andWhere("mac.alert_for", filter.alert_for);
}
if (filter.is_active !== undefined) {
query = query.andWhere("is_active", filter.is_active);
query = query.andWhere("mac.is_active", filter.is_active);
}
return await query.orderBy("id", "desc");
return await query.orderBy("mac.id", "desc");
}
/**
* Get all monitor alert configs for a specific monitor tag
*/
async getMonitorAlertConfigsByMonitorTag(monitorTag: string): Promise<MonitorAlertConfigRecord[]> {
return await this.knex("monitor_alerts_config").where({ monitor_tag: monitorTag }).orderBy("id", "desc");
return await this.knex("monitor_alerts_config as mac")
.select("mac.*")
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.where("macm.monitor_tag", monitorTag)
.orderBy("mac.id", "desc");
}
/**
@@ -124,9 +132,12 @@ export class MonitorAlertConfigRepository extends BaseRepository {
* Get all active monitor alert configs for a specific monitor
*/
async getActiveMonitorAlertConfigsByMonitorTag(monitorTag: string): Promise<MonitorAlertConfigRecord[]> {
return await this.knex("monitor_alerts_config")
.where({ monitor_tag: monitorTag, is_active: "YES" })
.orderBy("id", "desc");
return await this.knex("monitor_alerts_config as mac")
.select("mac.*")
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.where("macm.monitor_tag", monitorTag)
.andWhere("mac.is_active", "YES")
.orderBy("mac.id", "desc");
}
/**
@@ -140,26 +151,51 @@ export class MonitorAlertConfigRepository extends BaseRepository {
* Delete all monitor alert configs for a specific monitor tag
*/
async deleteMonitorAlertConfigsByMonitorTag(monitorTag: string): Promise<number> {
return await this.knex("monitor_alerts_config").where({ monitor_tag: monitorTag }).del();
// Find all config IDs that have this monitor tag in the junction table
const configIds = await this.knex("monitor_alerts_config_monitors")
.select("monitor_alerts_id")
.where({ monitor_tag: monitorTag });
if (configIds.length === 0) return 0;
// Remove the monitor from the junction table
await this.knex("monitor_alerts_config_monitors").where({ monitor_tag: monitorTag }).del();
// Delete any configs that now have zero monitors
const ids = configIds.map((r: { monitor_alerts_id: number }) => r.monitor_alerts_id);
let deletedCount = 0;
for (const id of ids) {
const remainingMonitors = await this.knex("monitor_alerts_config_monitors")
.count("* as count")
.where({ monitor_alerts_id: id })
.first<CountResult>();
if (Number(remainingMonitors?.count) === 0) {
await this.knex("monitor_alerts_config").where({ id }).del();
deletedCount++;
}
}
return deletedCount;
}
/**
* Count monitor alert configs with optional filtering
*/
async getMonitorAlertConfigsCount(filter: MonitorAlertConfigFilter): Promise<CountResult | undefined> {
let query = this.knex("monitor_alerts_config").count("* as count");
let query = this.knex("monitor_alerts_config as mac").count("* as count");
if (filter.id !== undefined) {
query = query.andWhere("id", filter.id);
query = query.andWhere("mac.id", filter.id);
}
if (filter.monitor_tag !== undefined) {
query = query.andWhere("monitor_tag", filter.monitor_tag);
query = query
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.andWhere("macm.monitor_tag", filter.monitor_tag);
}
if (filter.alert_for !== undefined) {
query = query.andWhere("alert_for", filter.alert_for);
query = query.andWhere("mac.alert_for", filter.alert_for);
}
if (filter.is_active !== undefined) {
query = query.andWhere("is_active", filter.is_active);
query = query.andWhere("mac.is_active", filter.is_active);
}
return await query.first<CountResult>();
@@ -174,29 +210,33 @@ export class MonitorAlertConfigRepository extends BaseRepository {
filter?: MonitorAlertConfigFilter,
): Promise<{ configs: MonitorAlertConfigRecord[]; total: number }> {
// Build count query
let countQuery = this.knex("monitor_alerts_config").count("* as count");
let countQuery = this.knex("monitor_alerts_config as mac").count("* as count");
if (filter?.monitor_tag) {
countQuery = countQuery.where("monitor_tag", filter.monitor_tag);
countQuery = countQuery
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.where("macm.monitor_tag", filter.monitor_tag);
}
if (filter?.is_active) {
countQuery = countQuery.andWhere("is_active", filter.is_active);
countQuery = countQuery.andWhere("mac.is_active", filter.is_active);
}
if (filter?.alert_for) {
countQuery = countQuery.andWhere("alert_for", filter.alert_for);
countQuery = countQuery.andWhere("mac.alert_for", filter.alert_for);
}
const totalResult = await countQuery.first<CountResult>();
const total = totalResult ? Number(totalResult.count) : 0;
// Build paginated query
let query = this.knex("monitor_alerts_config").orderBy("id", "desc");
let query = this.knex("monitor_alerts_config as mac").select("mac.*").orderBy("mac.id", "desc");
if (filter?.monitor_tag) {
query = query.where("monitor_tag", filter.monitor_tag);
query = query
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.where("macm.monitor_tag", filter.monitor_tag);
}
if (filter?.is_active) {
query = query.andWhere("is_active", filter.is_active);
query = query.andWhere("mac.is_active", filter.is_active);
}
if (filter?.alert_for) {
query = query.andWhere("alert_for", filter.alert_for);
query = query.andWhere("mac.alert_for", filter.alert_for);
}
const configs = await query.limit(limit).offset((page - 1) * limit);
@@ -278,6 +318,51 @@ export class MonitorAlertConfigRepository extends BaseRepository {
}
}
// ============ Monitor Alert Config Monitors CRUD ============
/**
* Add multiple monitors to an alert config
*/
async addMonitorsToAlertConfig(alertConfigId: number, monitorTags: string[]): Promise<void> {
if (monitorTags.length === 0) return;
const inserts = monitorTags.map((monitorTag) => ({
monitor_alerts_id: alertConfigId,
monitor_tag: monitorTag,
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
}));
await this.knex("monitor_alerts_config_monitors").insert(inserts);
}
/**
* Remove all monitors from an alert config
*/
async removeAllMonitorsFromAlertConfig(alertConfigId: number): Promise<number> {
return await this.knex("monitor_alerts_config_monitors").where({ monitor_alerts_id: alertConfigId }).del();
}
/**
* Replace all monitors for an alert config (remove old, add new)
*/
async replaceAlertConfigMonitors(alertConfigId: number, monitorTags: string[]): Promise<void> {
await this.removeAllMonitorsFromAlertConfig(alertConfigId);
if (monitorTags.length > 0) {
await this.addMonitorsToAlertConfig(alertConfigId, monitorTags);
}
}
/**
* Get monitor tags for an alert config
*/
async getAlertConfigMonitorTags(alertConfigId: number): Promise<string[]> {
const records = await this.knex("monitor_alerts_config_monitors")
.select("monitor_tag")
.where({ monitor_alerts_id: alertConfigId });
return records.map((r: { monitor_tag: string }) => r.monitor_tag);
}
// ============ Composite / Join Operations ============
/**
@@ -292,9 +377,12 @@ export class MonitorAlertConfigRepository extends BaseRepository {
.select("t.*")
.where("mact.monitor_alerts_id", id);
const monitorTags = await this.getAlertConfigMonitorTags(id);
return {
...config,
triggers: triggerRecords as TriggerRecord[],
monitor_tags: monitorTags,
};
}
@@ -311,9 +399,12 @@ export class MonitorAlertConfigRepository extends BaseRepository {
.select("t.*")
.where("mact.monitor_alerts_id", config.id);
const monitorTags = await this.getAlertConfigMonitorTags(config.id);
result.push({
...config,
triggers: triggerRecords as TriggerRecord[],
monitor_tags: monitorTags,
});
}
@@ -333,9 +424,12 @@ export class MonitorAlertConfigRepository extends BaseRepository {
.select("t.*")
.where("mact.monitor_alerts_id", config.id);
const monitorTags = await this.getAlertConfigMonitorTags(config.id);
result.push({
...config,
triggers: triggerRecords as TriggerRecord[],
monitor_tags: monitorTags,
});
}
@@ -373,6 +467,7 @@ export class MonitorAlertConfigRepository extends BaseRepository {
const dbType = GetDbType();
const insertData: Record<string, unknown> = {
config_id: data.config_id,
monitor_tag: data.monitor_tag || null,
incident_id: data.incident_id || null,
alert_status: data.alert_status,
created_at: this.knex.fn.now(),
@@ -432,6 +527,9 @@ export class MonitorAlertConfigRepository extends BaseRepository {
if (filter.config_id !== undefined) {
query = query.andWhere("config_id", filter.config_id);
}
if (filter.monitor_tag !== undefined) {
query = query.andWhere("monitor_tag", filter.monitor_tag);
}
if (filter.incident_id !== undefined) {
query = query.andWhere("incident_id", filter.incident_id);
}
+12 -2
View File
@@ -372,8 +372,18 @@ export class MonitoringRepository extends BaseRepository {
});
}
async deleteMonitorDataByTag(tag: string): Promise<number> {
return await this.knex("monitoring_data").where("monitor_tag", tag).del();
async deleteMonitorDataByTag(tag?: string, start?: number, end?: number): Promise<number> {
const query = this.knex("monitoring_data");
if (tag) {
query.where("monitor_tag", tag);
}
if (start !== undefined) {
query.where("timestamp", ">=", start);
}
if (end !== undefined) {
query.where("timestamp", "<=", end);
}
return await query.del();
}
/**
@@ -96,6 +96,12 @@ export class MonitorsRepository extends BaseRepository {
builder.whereIn("tag", data.tags as string[]);
});
}
if (!!data.search) {
const term = `%${data.search}%`;
query = query.andWhere((builder: KnexType.QueryBuilder) => {
builder.where("name", "like", term).orWhere("tag", "like", term);
});
}
return await query.orderBy("id", "desc");
}
+16 -2
View File
@@ -65,6 +65,7 @@ export class PagesRepository extends BaseRepository {
page_id: data.page_id,
monitor_tag: data.monitor_tag,
monitor_settings_json: data.monitor_settings_json,
position: data.position ?? 0,
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
});
@@ -75,7 +76,7 @@ export class PagesRepository extends BaseRepository {
}
async getPageMonitors(page_id: number): Promise<PageMonitorRecord[]> {
return await this.knex("pages_monitors").where("page_id", page_id).orderBy("created_at", "desc");
return await this.knex("pages_monitors").where("page_id", page_id).orderBy("position", "asc");
}
async getPageMonitorsExcludeHidden(page_id: number): Promise<PageMonitorRecord[]> {
@@ -84,7 +85,7 @@ export class PagesRepository extends BaseRepository {
.where("pages_monitors.page_id", page_id)
.andWhere("monitors.is_hidden", "NO")
.andWhere("monitors.status", "ACTIVE")
.orderBy("pages_monitors.created_at", "desc")
.orderBy("pages_monitors.position", "asc")
.select("pages_monitors.*");
}
@@ -115,4 +116,17 @@ export class PagesRepository extends BaseRepository {
async deletePageMonitorsByPageId(page_id: number): Promise<number> {
return await this.knex("pages_monitors").where({ page_id }).del();
}
async updatePageMonitorPositions(
page_id: number,
monitorPositions: { monitor_tag: string; position: number }[],
): Promise<void> {
await this.knex.transaction(async (trx) => {
for (const mp of monitorPositions) {
await trx("pages_monitors")
.where({ page_id, monitor_tag: mp.monitor_tag })
.update({ position: mp.position, updated_at: trx.fn.now() });
}
});
}
}
+19 -1
View File
@@ -35,7 +35,7 @@ const seedSiteData = {
},
],
nav: [
{ name: "Documentation", url: "https://kener.ing/docs/home", iconURL: "" },
{ name: "Documentation", url: "https://kener.ing/docs", iconURL: "" },
{ name: "GitHub", iconURL: "", url: "https://github.com/rajnandan1/kener" },
{ name: "Login", iconURL: "", url: "/account/signin" },
],
@@ -159,6 +159,24 @@ const seedSiteData = {
showSwitcher: true,
forceExclusivity: false,
},
dateAndTimeFormat: {
datePlusTime: "PPp",
dateOnly: "PP",
timeOnly: "p",
},
sitemap: {
mode: "off",
urls: [],
},
globalMaintenanceNotificationSettings: {
event_types: {
created: false,
reminder: true,
started: true,
ended: true,
},
reminder_buffer_hours: 1,
},
};
export default seedSiteData;
+52
View File
@@ -0,0 +1,52 @@
/**
* Server-side i18n helper for translating strings outside of Svelte components.
* Reads locale JSON files from disk using Node.js fs (compatible with both
* Vite dev server and esbuild production bundle).
*/
import { readdirSync, readFileSync } from "fs";
import { join } from "path";
const localeModules: Record<string, { name: string; mappings: Record<string, string> }> = {};
const localesDir = join(process.cwd(), "src", "lib", "locales");
try {
const files = readdirSync(localesDir).filter((f) => f.endsWith(".json"));
for (const file of files) {
const key = `/src/lib/locales/${file}`;
localeModules[key] = JSON.parse(readFileSync(join(localesDir, file), "utf-8"));
}
} catch {
console.warn("Could not load locale files from", localesDir);
}
const localeCache = new Map<string, Record<string, string>>();
function getMappings(locale: string): Record<string, string> {
if (localeCache.has(locale)) return localeCache.get(locale)!;
const key = `/src/lib/locales/${locale}.json`;
const mappings = localeModules[key]?.mappings ?? {};
localeCache.set(locale, mappings);
return mappings;
}
/**
* Translate a key for a given locale. Falls back to the default locale, then to the key itself.
*/
export function translate(locale: string, key: string, defaultLocale: string = "en"): string {
const mappings = getMappings(locale);
if (mappings[key]) return mappings[key];
if (locale !== defaultLocale) {
const fallbackMappings = getMappings(defaultLocale);
if (fallbackMappings[key]) return fallbackMappings[key];
}
return key;
}
/**
* Check if a locale code has a corresponding locale file.
*/
export function isLocaleAvailable(locale: string): boolean {
const key = `/src/lib/locales/${locale}.json`;
return key in localeModules;
}
@@ -1,5 +1,6 @@
import { GetRequiredSecrets, ReplaceAllOccurrences } from "../tool.js";
import Mustache from "mustache";
import version from "../../version.js";
export default async function send(
discordBody: string,
@@ -18,7 +19,7 @@ export default async function send(
}
const defaultHeaders = [
{ key: "user-agent", value: "Kener/4.0.0" },
{ key: "user-agent", value: `Kener/${version()}` },
{ key: "accept", value: "application/json" },
{ key: "content-type", value: "application/json" },
];
@@ -10,9 +10,11 @@ export function alertToVariables(
config: MonitorAlertConfigRecord,
alert: MonitorAlertV2Record,
siteVars: SiteDataForNotification,
monitorTag?: string,
): AlertVariableMap {
const createdAtDate = alert.created_at instanceof Date ? alert.created_at : new Date(alert.created_at);
const alert_name = `Alert ${config.monitor_tag} for ${config.alert_for} ${config.alert_value} ${alert.alert_status} at ${createdAtDate.toISOString()}`;
const effectiveMonitorTag = monitorTag || config.monitor_tag || "unknown";
const alert_name = `Alert ${effectiveMonitorTag} for ${config.alert_for} ${config.alert_value} ${alert.alert_status} at ${createdAtDate.toISOString()}`;
return {
alert_id: alert.id,
@@ -24,7 +26,7 @@ export function alertToVariables(
alert_message: config.alert_description || "",
alert_source: GC.ALERT,
alert_timestamp: createdAtDate.toISOString(),
alert_cta_url: siteVars.site_url + "monitors/" + config.monitor_tag,
alert_cta_url: siteVars.site_url + "monitors/" + effectiveMonitorTag,
alert_cta_text: "Open Alert Details",
alert_incident_id: alert.incident_id ? alert.incident_id : undefined,
alert_incident_url: alert.incident_id ? siteVars.site_url + "incidents/" + alert.incident_id : undefined,
@@ -1,5 +1,6 @@
import { GetRequiredSecrets, ReplaceAllOccurrences } from "../tool.js";
import Mustache from "mustache";
import version from "../../version.js";
export default async function send(
slackBody: string,
@@ -18,7 +19,7 @@ export default async function send(
}
const defaultHeaders = [
{ key: "user-agent", value: "Kener/4.0.0" },
{ key: "user-agent", value: `Kener/${version()}` },
{ key: "accept", value: "application/json" },
{ key: "content-type", value: "application/json" },
];
@@ -2,6 +2,7 @@ import type { WebhookTemplateJson } from "../types/db";
import { GetRequiredSecrets, ReplaceAllOccurrences } from "../tool.js";
import Mustache from "mustache";
import type { SiteDataForNotification, TemplateVariableMap } from "./types.js";
import version from "../../version.js";
export default async function send(
webhookBody: string,
@@ -10,12 +11,13 @@ export default async function send(
headers?: string,
) {
// Process trigger meta for environment secrets
let envSecrets = GetRequiredSecrets(webhookBody + headers);
let envSecrets = GetRequiredSecrets(webhookBody + headers + webhookURL);
for (let i = 0; i < envSecrets.length; i++) {
const secret = envSecrets[i];
if (secret.replace !== undefined) {
webhookBody = ReplaceAllOccurrences(webhookBody, secret.find, secret.replace);
webhookURL = ReplaceAllOccurrences(webhookURL, secret.find, secret.replace);
if (headers) {
headers = ReplaceAllOccurrences(headers, secret.find, secret.replace);
}
@@ -23,7 +25,7 @@ export default async function send(
}
const defaultHeaders = [
{ key: "user-agent", value: "Kener/4.0.0" },
{ key: "user-agent", value: `Kener/${version()}` },
{ key: "accept", value: "application/json" },
{ key: "content-type", value: "application/json" },
];
+9 -6
View File
@@ -141,8 +141,9 @@ async function sendAlertNotifications(
activeAlert: MonitorAlertV2Record,
monitor_alerts_configured: MonitorAlertConfigRecord,
templateSiteVars: SiteDataForNotification,
monitorTag?: string,
): Promise<void> {
const templateAlertVars = alertToVariables(monitor_alerts_configured, activeAlert, templateSiteVars);
const templateAlertVars = alertToVariables(monitor_alerts_configured, activeAlert, templateSiteVars, monitorTag);
const triggers = await GetTriggersByMonitorAlertConfigId(monitor_alerts_configured.id);
for (let i = 0; i < triggers.length; i++) {
@@ -233,9 +234,10 @@ const addWorker = () => {
return;
}
// Get existing alerts
// Get existing alerts for this specific monitor + config combination
let alertsExisting = await GetMonitorAlertsV2({
config_id: monitor_alerts_configured.id,
monitor_tag: monitor_tag,
alert_status: GC.TRIGGERED,
});
let activeAlert = null;
@@ -244,9 +246,9 @@ const addWorker = () => {
}
if (isAffected) {
// Trigger alert if not already active
// Trigger alert if not already active for this monitor
if (!activeAlert) {
activeAlert = await CreateMonitorAlertV2(monitor_alerts_configured.id);
activeAlert = await CreateMonitorAlertV2(monitor_alerts_configured.id, monitor_tag);
if (monitor_alerts_configured.create_incident === GC.YES) {
let newIncidentNumber = await createNewIncident(
activeAlert,
@@ -261,7 +263,7 @@ const addWorker = () => {
}
}
// Send triggered alert notifications
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars);
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars, monitor_tag);
}
} else {
// Resolve any existing alert
@@ -302,7 +304,7 @@ const addWorker = () => {
}
// Send resolution notifications
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars);
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars, monitor_tag);
}
}
} catch (error) {
@@ -348,6 +350,7 @@ export const push = async (monitor_tag: string, ts: number, status: string, opti
monitor_tag: monitor.tag,
is_active: GC.YES,
});
if (monitorAlertsConfigurations.length === 0) {
return;
}
+1 -1
View File
@@ -46,7 +46,7 @@ const addWorker = () => {
fromEmail,
templateTextBody,
);
console.log(`📧 Email sent to ${toEmails}`);
// console.log(`📧 Email sent to ${toEmails}`);
} catch (error) {
console.error(`Failed to send email to ${toEmails}:`, error);
throw error; // Re-throw to trigger retry
@@ -42,6 +42,7 @@ const addWorker = () => {
});
if (!dbRes) {
console.error("Failed to insert monitoring data for monitorTag:", monitorTag, "timestamp:", ts);
throw new Error("Failed to insert monitoring data");
}
@@ -52,7 +53,6 @@ const addWorker = () => {
latency: latency,
type: type,
});
alertingQueue.push(monitorTag, ts, status);
return dbRes;
@@ -4,7 +4,7 @@ import db from "../db/db.js";
import { rrulestr } from "rrule";
import { addDays } from "date-fns";
import type { MaintenanceRecord, MaintenanceEventRecord } from "../types/db.js";
import { determineEventStatus } from "../controllers/maintenanceController.js";
import { determineEventStatus, CreateMaintenanceEventWithNotification } from "../controllers/maintenanceController.js";
let maintenanceSchedulerQueue: Queue | null = null;
let worker: Worker | null = null;
@@ -58,12 +58,13 @@ const generateEventsForMaintenance = async (maintenance: MaintenanceRecord): Pro
// Check if event already exists for this start time
if (!existingStartTimes.has(eventStart)) {
await db.createMaintenanceEvent({
maintenance_id: maintenance.id,
start_date_time: eventStart,
end_date_time: eventEnd,
status: determineEventStatus(eventStart, eventEnd),
});
await CreateMaintenanceEventWithNotification(
maintenance.id,
eventStart,
eventEnd,
maintenance.title,
maintenance.description || null,
);
eventsCreated++;
console.log(
`Created maintenance event for "${maintenance.title}" at ${new Date(eventStart * 1000).toISOString()}`,
+13 -6
View File
@@ -154,25 +154,32 @@ class ApiCall {
}
}
if (!!!evalResp) {
if (!evalResp || typeof evalResp !== "object") {
evalResp = {
status: GC.DOWN,
latency: latency,
type: GC.ERROR,
};
} else if (
!!!evalResp.status ||
([GC.UP, GC.DOWN, GC.DEGRADED, GC.MAINTENANCE] as string[]).indexOf(evalResp.status) === -1
) {
errorMessage += " | Eval must return an object with 'status' and 'latency' fields, got no response";
} else if (evalResp.status === undefined) {
evalResp = {
status: GC.DOWN,
latency: latency,
type: GC.ERROR,
};
errorMessage += ` | Eval must return an object with a 'status' field (one of: ${GC.UP}, ${GC.DOWN}, ${GC.DEGRADED}, ${GC.MAINTENANCE}), but 'status' was missing`;
} else if (([GC.UP, GC.DOWN, GC.DEGRADED, GC.MAINTENANCE] as string[]).indexOf(evalResp.status) === -1) {
evalResp = {
status: GC.DOWN,
latency: latency,
type: GC.ERROR,
};
errorMessage += ` | Eval returned invalid 'status' value "${evalResp.status}". Must be one of: ${GC.UP}, ${GC.DOWN}, ${GC.DEGRADED}, ${GC.MAINTENANCE}`;
} else {
evalResp.type = GC.REALTIME;
// Ensure latency is a valid number
// Ensure latency is a valid number; fall back to measured latency
if (typeof evalResp.latency !== "number" || isNaN(evalResp.latency)) {
errorMessage += ` | Eval 'latency' must be a number, got ${JSON.stringify(evalResp.latency)}. Using measured latency instead`;
evalResp.latency = latency;
}
}
+16 -13
View File
@@ -5,22 +5,24 @@ import { GetLastMonitoringValue } from "../cache/setGet.js";
import { GetLatestMonitoringData } from "../controllers/controller.js";
/**
* Numeric scores for each status.
* UP = 0, DEGRADED = 1, DOWN = 2, MAINTENANCE = 3
* Numeric scores for each status (normalized 01).
* UP = 1, DEGRADED = 0.5, DOWN = 0
* MAINTENANCE members are treated as UP (score 1).
*
* Weighted sum = Σ(weight × score), weights should sum to 1.
* Result: 1 → UP, (0, 1) → DEGRADED, 0 → DOWN
*/
const STATUS_SCORE: Record<string, number> = {
[GC.UP]: 0,
[GC.DEGRADED]: 1,
[GC.DOWN]: 2,
[GC.MAINTENANCE]: 3,
[GC.UP]: 1,
[GC.DEGRADED]: 0.5,
[GC.DOWN]: 0,
};
/** Map a weighted score back to a status string. */
function scoreToStatus(score: number): string {
if (score >= 3) return GC.MAINTENANCE;
if (score >= 2) return GC.DOWN;
if (score >= 1) return GC.DEGRADED;
return GC.UP;
if (score >= 1) return GC.UP;
if (score > 0) return GC.DEGRADED;
return GC.DOWN;
}
class GroupCall {
@@ -64,16 +66,17 @@ class GroupCall {
}
// --- Status calculation via weighted scores ---
// Each status has a numeric score: UP=0, DEGRADED=1, DOWN=2, MAINTENANCE=3
// Each status has a normalized score: UP=1, DEGRADED=0.5, DOWN=0
// MAINTENANCE members are treated as UP (score 1).
// Weighted sum = Σ(weight × score), weights should sum to 1.
// Result is mapped back: <1 → UP, ≥1 → DEGRADED, ≥2 → DOWN, ≥3 → MAINTENANCE
// Result: 1 → UP, (0,1) → DEGRADED, 0 → DOWN
let weightedScore = 0;
let totalWeight = 0;
for (const member of members) {
const data = statusMap.get(member.tag);
if (!data) continue;
const score = STATUS_SCORE[data.status] ?? 0;
const score = STATUS_SCORE[data.status] ?? 1;
weightedScore += member.weight * score;
totalWeight += member.weight;
}
+139
View File
@@ -0,0 +1,139 @@
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
import { writeFileSync, unlinkSync, mkdtempSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import { performance } from "node:perf_hooks";
import GC from "../../global-constants.js";
import type { GrpcMonitor, MonitoringResult } from "../types/monitor.js";
// Standard gRPC Health Checking Protocol proto definition (grpc.health.v1).
// Embedded as a string to avoid file-path issues in bundled production builds.
const HEALTH_PROTO = `syntax = "proto3";
package grpc.health.v1;
message HealthCheckRequest {
string service = 1;
}
message HealthCheckResponse {
enum ServingStatus {
UNKNOWN = 0;
SERVING = 1;
NOT_SERVING = 2;
SERVICE_UNKNOWN = 3;
}
ServingStatus status = 1;
}
service Health {
rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
}`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let cachedProto: any = null;
function getHealthProto() {
if (!cachedProto) {
const tmpDir = mkdtempSync(path.join(tmpdir(), "kener-grpc-"));
const protoPath = path.join(tmpDir, "health.proto");
writeFileSync(protoPath, HEALTH_PROTO);
const packageDef = protoLoader.loadSync(protoPath, {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
cachedProto = grpc.loadPackageDefinition(packageDef);
unlinkSync(protoPath);
}
return cachedProto;
}
class GrpcCall {
monitor: GrpcMonitor;
constructor(monitor: GrpcMonitor) {
this.monitor = monitor;
}
async execute(): Promise<MonitoringResult> {
const { host, port, service, tls, timeout } = this.monitor.type_data;
const timeoutMs = timeout || 10000;
const target = `${host}:${port}`;
const start = performance.now();
try {
const proto = getHealthProto();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const healthService = (proto.grpc as any).health.v1.Health;
const credentials = tls ? grpc.credentials.createSsl() : grpc.credentials.createInsecure();
const client = new healthService(target, credentials);
const deadline = new Date(Date.now() + timeoutMs);
const result = await new Promise<{ status: string }>((resolve, reject) => {
client.Check(
{ service: service || "" },
{ deadline },
(err: grpc.ServiceError | null, response: { status: string }) => {
client.close();
if (err) {
reject(err);
} else {
resolve(response);
}
},
);
});
const latency = Math.round(performance.now() - start);
if (result.status === "SERVING") {
return {
status: GC.UP,
latency,
type: GC.REALTIME,
};
} else if (result.status === "NOT_SERVING") {
return {
status: GC.DOWN,
latency,
type: GC.REALTIME,
error_message: `Service status: ${result.status}`,
};
} else {
return {
status: GC.DEGRADED,
latency,
type: GC.REALTIME,
error_message: `Service status: ${result.status}`,
};
}
} catch (err: unknown) {
const latency = Math.round(performance.now() - start);
const error = err as { code?: number; details?: string; message?: string };
let errorMessage = error.details || error.message || "Unknown gRPC error";
let isTimeout = false;
if (error.code === grpc.status.DEADLINE_EXCEEDED) {
isTimeout = true;
errorMessage = "Request timed out";
}
return {
status: GC.DOWN,
latency,
type: isTimeout ? GC.TIMEOUT : GC.ERROR,
error_message: errorMessage,
};
}
}
}
export default GrpcCall;
+5
View File
@@ -8,6 +8,7 @@ import SqlCall from "./sqlCall.js";
import HeartbeatCall from "./heartbeatCall.js";
import GamedigCall from "./gamedigCall.js";
import NoneCall from "./noneCall.js";
import GrpcCall from "./grpcCall.js";
import type {
ApiMonitor,
@@ -19,6 +20,7 @@ import type {
SqlMonitor,
HeartbeatMonitor,
GamedigMonitor,
GrpcMonitor,
MonitoringResult,
NoneMonitor,
} from "../types/monitor.js";
@@ -33,6 +35,7 @@ type ServiceCall =
| SqlCall
| HeartbeatCall
| GamedigCall
| GrpcCall
| NoneCall;
export interface MonitorWithType {
@@ -64,6 +67,8 @@ class Service {
this.service = new HeartbeatCall(monitor as HeartbeatMonitor);
} else if (monitor.monitor_type === "GAMEDIG") {
this.service = new GamedigCall(monitor as GamedigMonitor);
} else if (monitor.monitor_type === "GRPC") {
this.service = new GrpcCall(monitor as GrpcMonitor);
} else {
this.service = new NoneCall(monitor as NoneMonitor);
}
+14 -1
View File
@@ -55,7 +55,7 @@ class TcpCall {
error_message: `Error in tcpEval: ${message}`,
};
}
if (!!!evalResp) {
if (!evalResp) {
const message = "tcpEval did not return a valid response.";
console.log(`Error in tcpEval for ${tag}:`, message);
return {
@@ -65,6 +65,19 @@ class TcpCall {
error_message: `Error in tcpEval: ${message}`,
};
}
//evalResp to be an object with status and latency
if (!("status" in evalResp) || !("latency" in evalResp)) {
const message = "tcpEval did not return status or latency.";
console.log(`Error in tcpEval for ${tag}:`, message);
return {
status: GC.DOWN,
latency: 0,
type: GC.ERROR,
error_message: `Error in tcpEval: ${message}`,
};
}
//reduce to get the status
return {
status: evalResp?.status || GC.DOWN,
@@ -7,7 +7,7 @@ const template =
' "alert_cta_url": "{{alert_cta_url}}",\n ' +
' "alert_cta_text": "{{alert_cta_text}}",\n ' +
'"alert_incident_id": {{#alert_incident_id}}{{alert_incident_id}}{{/alert_incident_id}}{{^alert_incident_id}}null{{/alert_incident_id}},\n ' +
'"alert_incident_url": {{#alert_incident_url}}{{alert_incident_url}}{{/alert_incident_url}}{{^alert_incident_url}}null{{/alert_incident_url}},\n ' +
'"alert_incident_url": {{#alert_incident_url}}"{{alert_incident_url}}"{{/alert_incident_url}}{{^alert_incident_url}}null{{/alert_incident_url}},\n ' +
'"alert_failure_threshold": {{alert_failure_threshold}},\n ' +
' "alert_success_threshold": {{alert_success_threshold}},\n ' +
'"is_resolved": {{is_resolved}},\n "is_triggered": {{is_triggered}},\n ' +
+28 -3
View File
@@ -444,6 +444,9 @@ export interface PageSettingsType {
mobile: number;
};
monitor_layout_style: "default-list" | "default-grid" | "compact-list" | "compact-grid";
metaPageTitle?: string;
metaPageDescription?: string;
socialPagePreviewImage?: string;
}
export interface PageRecordTyped {
@@ -463,6 +466,7 @@ export interface PageMonitorRecord {
page_id: number;
monitor_tag: string;
monitor_settings_json: string | null;
position: number;
created_at: Date;
updated_at: Date;
}
@@ -471,12 +475,14 @@ export interface PageMonitorRecordInsert {
page_id: number;
monitor_tag: string;
monitor_settings_json?: string | null;
position?: number;
}
export interface PageMonitorRecordTyped {
page_id: number;
monitor_tag: string;
monitor_settings: Record<string, unknown> | null;
position: number;
created_at: Date;
updated_at: Date;
}
@@ -622,7 +628,7 @@ export type YesNoType = "YES" | "NO";
export interface MonitorAlertConfigRecord {
id: number;
monitor_tag: string;
monitor_tag: string | null;
alert_for: AlertForType;
alert_value: string;
failure_threshold: number;
@@ -636,7 +642,7 @@ export interface MonitorAlertConfigRecord {
}
export interface MonitorAlertConfigInsert {
monitor_tag: string;
monitor_tag?: string | null;
alert_for: AlertForType;
alert_value: string;
failure_threshold: number;
@@ -678,13 +684,27 @@ export interface MonitorAlertConfigTriggerInsert {
trigger_id: number;
}
// ============ monitor_alerts_config_monitors table ============
export interface MonitorAlertConfigMonitorRecord {
monitor_alerts_id: number;
monitor_tag: string;
created_at: Date;
updated_at: Date;
}
export interface MonitorAlertConfigMonitorInsert {
monitor_alerts_id: number;
monitor_tag: string;
}
// ============ Composite types for monitor_alerts_config ============
export interface MonitorAlertConfigWithTriggers extends MonitorAlertConfigRecord {
triggers: TriggerRecord[];
monitor_tags: string[];
}
export interface MonitorAlertConfigCreateInput {
monitor_tag: string;
monitor_tags: string[];
alert_for: AlertForType;
alert_value: string;
failure_threshold: number;
@@ -698,6 +718,7 @@ export interface MonitorAlertConfigCreateInput {
export interface MonitorAlertConfigUpdateInput {
id: number;
monitor_tags?: string[];
alert_for?: AlertForType;
alert_value?: string;
failure_threshold?: number;
@@ -715,6 +736,7 @@ export type MonitorAlertStatusType = "TRIGGERED" | "RESOLVED";
export interface MonitorAlertV2Record {
id: number;
config_id: number;
monitor_tag: string | null;
incident_id: number | null;
alert_status: MonitorAlertStatusType;
created_at: Date;
@@ -723,11 +745,13 @@ export interface MonitorAlertV2Record {
export interface MonitorAlertV2Insert {
config_id: number;
monitor_tag?: string | null;
incident_id?: number | null;
alert_status: MonitorAlertStatusType;
}
export interface MonitorAlertV2Update {
monitor_tag?: string | null;
incident_id?: number | null;
alert_status?: MonitorAlertStatusType;
}
@@ -735,6 +759,7 @@ export interface MonitorAlertV2Update {
export interface MonitorAlertV2Filter {
id?: number;
config_id?: number;
monitor_tag?: string;
incident_id?: number;
alert_status?: MonitorAlertStatusType;
}
+11 -1
View File
@@ -78,6 +78,14 @@ export interface GamedigMonitorTypeData {
requestRules?: boolean;
}
export interface GrpcMonitorTypeData {
host: string;
port: number;
service?: string;
tls?: boolean;
timeout?: number;
}
export type MonitorTypeData =
| ApiMonitorTypeData
| DnsMonitorTypeData
@@ -87,7 +95,8 @@ export type MonitorTypeData =
| SqlMonitorTypeData
| HeartbeatMonitorTypeData
| GroupMonitorTypeData
| GamedigMonitorTypeData;
| GamedigMonitorTypeData
| GrpcMonitorTypeData;
export interface Monitor<T = MonitorTypeData> {
tag: string;
@@ -105,6 +114,7 @@ export type SqlMonitor = Monitor<SqlMonitorTypeData>;
export type HeartbeatMonitor = Monitor<HeartbeatMonitorTypeData>;
export type GroupMonitor = Monitor<GroupMonitorTypeData>;
export type GamedigMonitor = Monitor<GamedigMonitorTypeData>;
export type GrpcMonitor = Monitor<GrpcMonitorTypeData>;
export interface EvalResponse {
status?: string;
@@ -28,7 +28,12 @@
}
gtag("js", new Date());
gtag("config", "{{id}}");
var gtagConfig = {};
var transportUrl = "{{transport_url}}";
if (!!transportUrl) {
gtagConfig.transport_url = transportUrl;
}
gtag("config", "{{id}}", gtagConfig);
window.addEventListener("analyticsEvent", function (e) {
// Extract event name and data from the custom event
@@ -43,6 +48,7 @@
//on dom ready
document.addEventListener("DOMContentLoaded", function () {
loadJS("https://www.googletagmanager.com/gtag/js?id={{id}}", initJS, document.getElementsByTagName("head")[0]);
var scriptHost = "{{script_host}}" || "https://www.googletagmanager.com/gtag/js?id={{id}}";
loadJS(scriptHost, initJS, document.getElementsByTagName("head")[0]);
});
})();
+3 -3
View File
@@ -37,7 +37,7 @@ function createTimezoneStore() {
// Check localStorage for saved preference (only in browser)
if (typeof window !== "undefined") {
const savedTimezone = localStorage.getItem(TIMEZONE_STORAGE_KEY);
if (savedTimezone && allTimezones.includes(savedTimezone)) {
if (savedTimezone && (savedTimezone === "UTC" || allTimezones.includes(savedTimezone))) {
preferredTimezone = savedTimezone;
} else {
// Save the default timezone to localStorage
@@ -47,7 +47,7 @@ function createTimezoneStore() {
set({
selectedTimezone: preferredTimezone,
availableTimezones: allTimezones,
availableTimezones: ["UTC", ...allTimezones],
});
},
@@ -56,7 +56,7 @@ function createTimezoneStore() {
*/
setTimezone(timezone: string): void {
update((state) => {
if (state.availableTimezones.includes(timezone)) {
if (timezone === "UTC" || state.availableTimezones.includes(timezone)) {
// Save to localStorage
if (typeof window !== "undefined") {
localStorage.setItem(TIMEZONE_STORAGE_KEY, timezone);
+1
View File
@@ -15,6 +15,7 @@ export const MONITOR_TYPES = [
"SQL",
"HEARTBEAT",
"GAMEDIG",
"GRPC",
] as const;
export type MonitorType = (typeof MONITOR_TYPES)[number];

Some files were not shown because too many files have changed in this diff Show More