first commit for version 4, very unstable

This commit is contained in:
Raj Nandan Sharma
2026-01-22 11:19:13 +05:30
parent 0d31187ab4
commit 0149b3e61a
659 changed files with 59328 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
node_modules
.git
.github
.vscode
dist
build
.env
.DS_Store
*.log
+58
View File
@@ -0,0 +1,58 @@
# EditorConfig helps maintain consistent coding styles between editors
root = true
# Default settings for all files (e.g. most common, best-practice standard across all filetypes)
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
# Svelte files
[*.svelte]
indent_style = space
indent_size = 2
trim_trailing_whitespace = false
# JavaScript and TypeScript
[*.{js,ts,tsx,cjs,mjs}]
indent_style = space
indent_size = 2
# JSON files (package.json, config files, etc.) - per JSON (RFC 8259) specification
[*.json,.prettierrc]
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = false
# YAML files (e.g., GitHub Actions, Lint configs) - per YAML 1.2 (2009) specification
[*.{yaml,yml}]
indent_style = space
indent_size = 2
# CSS & PostCSS files
[*.{css,postcss}]
indent_style = space
indent_size = 2
# Markdown files
[*.md]
indent_style = space
indent_size = 4
trim_trailing_whitespace = false
print_width = 180
# Dockerfile
[Dockerfile*]
indent_style = tab
indent_size = 4
insert_final_newline = false
# Ignore binary files
[*.{png,jpg,jpeg,gif,ico,svg,woff,woff2,eot,ttf,otf}]
charset = unset
trim_trailing_whitespace = false
insert_final_newline = false
+48
View File
@@ -0,0 +1,48 @@
# Contributing to Kener
Thank you for considering contributing to our project! Here are some guidelines to help you get started.
---
## How to Contribute
1. Fork the repository and clone it locally.
2. Create a new branch for your feature or bug fix:
```bash
git checkout -b feature/your-feature-name
```
3. Make your changes and commit them:
```bash
git commit -m 'Describe your changes'
```
4. Push your changes to your fork:
```bash
git push origin feature/your-feature-name
```
5. Create a pull request to the `main` branch.
## Development
1. Install dependencies:
```bash
npm install
```
2. Create a `.env` file in the root of the project and add the following:
```bash
cp .env.example .env
```
2. Start the development server:
```bash
npm run dev
```
3. Open [http://localhost:3000](http://localhost:3000) in your browser.
## Documentation
The documentation is available in the `docs` folder. You can view it by going to [http://localhost:3000/docs/home](http://localhost:3000/docs/home) in your browser.
## Where to Start
1. Check out the [roadmap items](https://kener.ing/docs/roadmap/)
2. Add language support by following the [i18n guide](https://kener.ing/docs/i18n/)
+2
View File
@@ -0,0 +1,2 @@
github: rajnandan1
buy_me_a_coffee: rajnandan1
+36
View File
@@ -0,0 +1,36 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Version**
Which version of kener you are using.
**Environment**
Which environment you are using or where is it deployed. `docker`, `kubernetes`, `bare-metal`, `development`, `pm2` etc
**Database**
Which database you are using. `sqlite`, `mysql`, `postgres`
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+118
View File
@@ -0,0 +1,118 @@
# Kener - AI Coding Instructions
## 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.
## 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)
### Route Groups (SvelteKit)
- **`(kener)/`** - Public status page routes
- **`(manage)/`** - Admin dashboard (requires authentication)
- **`(embed)/`** - Embeddable widgets
- **`(docs)/`** - Documentation pages
### 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
### Database
- Supports SQLite (default), PostgreSQL, MySQL via **Knex.js**
- Connection string format: `sqlite://./path` or `postgresql://...` or `mysql://...`
- Migrations in `/migrations/`, seeds in `/seeds/`
- Run migrations: `npm run migrate` or auto-runs on `npm start`
## 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)
```
## 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`).
### 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
```
### Status Constants
Use constants from `src/lib/server/constants`:
```javascript
import { UP, DOWN, DEGRADED, MAINTENANCE, NO_DATA } from "./constants";
```
### API Authentication
APIs use Bearer token auth verified via `VerifyAPIKey()`:
```javascript
import { VerifyAPIKey } from "$lib/server/controllers/controller.js";
```
### Database Queries
Always use the db singleton, never instantiate Knex directly:
```javascript
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";
```
### i18n
Locales are in `src/lib/locales/`. 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
import { Button } from "$lib/components/ui/button";
```
Styling: **TailwindCSS** with HSL CSS variables for theming (see `tailwind.config.js`).
## Environment Variables
Required in `.env`:
- `KENER_SECRET_KEY` - JWT secret for auth
- `ORIGIN` - Site URL (e.g., `http://localhost:3000`)
- `DATABASE_URL` - Database connection string
Optional:
- `KENER_BASE_PATH` - Base path for reverse proxy
- `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)
- API endpoints: `+server.ts` files returning `json()`
## Types & Interfaces
Place types and interfaces in the appropriate folder based on where they are used:
- **`src/lib/types/`** - Shared types (safe to import from both server and client code). Use for domain models, DTOs, API response types, and anything needed on both sides.
- **`src/lib/server/types/`** - Server-only types. 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.
Always use `import type { ... }` when importing types to avoid accidental runtime imports.
+47
View File
@@ -0,0 +1,47 @@
version: 2
updates:
# Track base image versions via .env.build
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
file-patterns:
- ".env.build"
- "node:*" # Ensures Node.js images are correctly detected
# Monitor OS package versions in Dockerfile (Debian/Alpine)
- package-ecosystem: "gitsubmodule" # Alternative method to track OS packages in Dockerfile
directory: "/"
schedule:
interval: "weekly"
labels:
- "dependencies"
- "os-packages"
commit-message:
prefix: "os"
include: "scope"
# Monitor Node.js dependencies from package.json
# TODO: Uncomment below if we want to begin letting Dependabot monitor & open PRs for Node.js project dependencies
# - package-ecosystem: "npm"
# directory: "/"
# schedule:
# interval: "weekly"
# labels:
# - "dependencies"
# - "npm"
# commit-message:
# prefix: "npm"
# include: "scope"
# Monitor GitHub Actions dependencies
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
labels:
- "dependencies"
- "github-actions"
commit-message:
prefix: "actions"
include: "scope"
+83
View File
@@ -0,0 +1,83 @@
name: Generate README
on:
push:
branches:
- main
paths:
- 'README.template.md'
pull_request:
branches:
- main
paths:
- 'README.template.md'
workflow_dispatch: # Allows for manual execution
workflow_run: # Triggers this workflow to run when it recognizes 'publish-images' workflow has ran and successfully completed
workflows: ["Publish Docker Image to Registries"]
types:
- completed
permissions:
contents: write # Explicitly allow pushing changes
jobs:
generate-readme:
name: Generate README from template
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0 # Fetch full history, including tags
persist-credentials: false # We'll manually authenticate
- name: Configure Git
run: |
git config --global user.name "github-actions"
git config --global user.email "github-actions@github.com"
- name: Setup Node.js
uses: actions/setup-node@v4.2.0
with:
node-version: "20"
- name: Install Dependencies
run: npm install mustache dotenv
- name: Extract Major and Major-Minor Versions
run: |
VERSION="${{ vars.BUILD_VERSION }}"
# Check if VERSION is empty and set a fallback value
if [ -z "$VERSION" ]; then
# Fetch the latest release using Git
VERSION=$(git tag -l --sort=-v:refname | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || echo "3.1.0")
fi
MAJOR=$(echo "$VERSION" | cut -d. -f1)
MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2)
echo "Full version: $VERSION"
echo "Major version: $MAJOR"
echo "Major-Minor version: $MAJOR_MINOR"
# Export all as environment variables
echo "LATEST_VERSION=$VERSION" >> $GITHUB_ENV
echo "LATEST_MAJOR_VERSION=$MAJOR" >> $GITHUB_ENV
echo "LATEST_MAJOR_MINOR_VERSION=$MAJOR_MINOR" >> $GITHUB_ENV
- name: Generate README.md
env:
BUILD_FULL_VERSION: ${{ env.LATEST_VERSION }} # e.g., 1.2.3
BUILD_MAJOR_VERSION: ${{ env.LATEST_MAJOR_VERSION}} # e.g., 1
BUILD_MAJOR_MINOR_VERSION: ${{ env.LATEST_MAJOR_MINOR_VERSION }} # e.g., 1.2
run: node scripts/generate-readme.js
- name: Commit and Push Changes
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
git add README.md
git commit -m "Auto-generate README.md with release versions" || echo "No changes to commit"
git push https://x-access-token:${{ secrets.GH_PAT }}@github.com/${{ github.repository }}.git HEAD:main
+21
View File
@@ -0,0 +1,21 @@
name: Prevent Direct README Changes
on:
pull_request:
paths:
- "README.md"
jobs:
check-readme:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4.2.2
- name: Detect direct README changes
run: |
if git diff --name-only origin/main | grep -q "README.md"; then
echo "❌ Direct modifications to README.md are not allowed!"
echo "Please update README.template.md instead."
exit 1
fi
+156
View File
@@ -0,0 +1,156 @@
name: Publish Docker Image to Registries
on:
release:
types:
- published # Runs only when a GitHub Release is published
workflow_dispatch: # Allows for manual execution
env:
ALPINE_VERSION: "23-alpine"
DEBIAN_VERSION: "23-slim"
# Registry URLs
DOCKERHUB_REGISTRY: docker.io
GITHUB_REGISTRY: ghcr.io
# Docker Hub image name (using Docker Hub username)
DOCKERHUB_IMAGE_NAME: ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }}
# GitHub image name (formatted as `account/repo`)
GITHUB_IMAGE_NAME: ${{ github.repository }}
jobs:
check-lockfile:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '23' # Adjust as needed
- name: Install dependencies
run: npm ci
- name: Check for lock-file changes
run: |
git diff --exit-code package-lock.json || (
echo "🫥 package-lock.json is outdated or missing. Please run 'npm install' and commit the updated lockfile."
exit 1
)
build-and-push-to-registries:
needs: check-lockfile # Runs only after `check-lockfile` completes successfully
name: Push Docker images to Docker Hub and GitHub Container Registry
strategy:
matrix:
variant: [alpine, debian]
runs-on: ubuntu-latest
permissions:
actions: write
contents: write
packages: write
# This is used to complete the identity challenge with sigstore/fulcio when running outside of PRs.
id-token: write
steps:
- name: Check out the repo
uses: actions/checkout@v4.2.2
# Install the cosign tool (except on PR)
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@v3.8.0
with:
cosign-release: 'v2.2.4'
# Set up BuildKit Docker container builder to be able to build multi-platform images and export cache
# https://github.com/docker/setup-buildx-action
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.8.0
# Log in to Docker Hub (except on PR)
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3.3.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
# Log in to GitHub Container Registry (except on PR)
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3.3.0
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
# Combined metadata extraction for both registries
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5.6.1
with:
images: |
${{ env.DOCKERHUB_IMAGE_NAME }}
${{ env.GITHUB_REGISTRY }}/${{ env.GITHUB_IMAGE_NAME }}
tags: |
# Debian Variant Tags
type=semver,pattern={{version}},enable=${{ matrix.variant == 'debian' }}
type=semver,pattern={{major}}.{{minor}},enable=${{ matrix.variant == 'debian' }}
type=semver,pattern={{major}},enable=${{ matrix.variant == 'debian' }}
type=raw,value=latest,enable=${{ matrix.variant == 'debian' }}
# Alpine Variant Tags
type=semver,pattern={{version}},suffix=-alpine,enable=${{ matrix.variant == 'alpine' }}
type=semver,pattern={{major}}.{{minor}},suffix=-alpine,enable=${{ matrix.variant == 'alpine' }}
type=semver,pattern={{major}},suffix=-alpine,enable=${{ matrix.variant == 'alpine' }}
type=raw,value=alpine,enable=${{ matrix.variant == 'alpine' }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.3.0
# Build and push Docker image with Buildx to both registries (don't push on PR)
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v6.13.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VARIANT=${{ matrix.variant }}
ALPINE_VERSION=${{ env.ALPINE_VERSION }}
DEBIAN_VERSION=${{ env.DEBIAN_VERSION }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
# Sign the resulting Docker image digests
- name: Sign the published Docker images
if: ${{ github.event_name != 'pull_request' }}
env:
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
run: |
echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
# For use in other workflows (e.g. 'generate-readme', etc.)
- name: Save Build Version to Repository Variable
if: matrix.variant == 'debian' && github.run_attempt == 1
run: |
# VERSION="${{ steps.meta.outputs.version }}"
VERSION=$(gh release view --json tagName -q .tagName 2>/dev/null || echo "")
# Check if VERSION is empty and set a fallback value
if [ -z "$VERSION" ]; then
VERSION=$(git tag -l --sort=-version:refname | grep -E '^(v)?[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || echo "3.1.0")
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "Setting BUILD_VERSION to $VERSION"
gh variable set BUILD_VERSION --body "$VERSION"
env:
GH_TOKEN: ${{ secrets.GH_PAT }} # Needs to be PAT w/ Read access to metadata and secrets & Read and Write access to actions, actions variables, and code
+31
View File
@@ -0,0 +1,31 @@
.DS_Store
node_modules
static/kener
build
config/monitors.yaml
config/site.yaml
config/server.yaml
/.svelte-kit
/src/lib/.kener
/package
.env
.vscode
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
nodemon.json
.okgit/
config/static/*
!config/static/.kener
db/*
!db/.kener
database/*
!database/.kener
uploads/*
!uploads/upload.dir
static/uploads/*
!static/uploads/upload.dir
temp.txt
temp.js
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+22
View File
@@ -0,0 +1,22 @@
.DS_Store
node_modules
static/kener
build
config/monitors.yaml
config/site.yaml
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
.okgit/
config/static/*
!config/static/.kener
**/*.yaml
**/*.yml
.github/
+71
View File
@@ -0,0 +1,71 @@
{
"useTabs": false,
"semi": true,
"tabWidth": 2,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte",
"useTabs": false,
"semi": true,
"tabWidth": 2,
"trailingComma": "none",
"printWidth": 120
}
},
{
"files": ["*.js", "*.ts", "*.tsx", "*.cjs", "*.mjs"],
"options": {
"useTabs": false,
"semi": true,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 120
}
},
{
"files": ["*.json", ".prettierrc"],
"options": {
"useTabs": false,
"semi": false,
"tabWidth": 2,
"trailingComma": "none",
"printWidth": 120
}
},
{
"files": ["*.yaml", "*.yml"],
"options": {
"useTabs": false,
"semi": false,
"tabWidth": 2,
"trailingComma": "none",
"printWidth": 80
}
},
{
"files": "*.md",
"options": {
"useTabs": false,
"semi": false,
"tabWidth": 4,
"trailingComma": "none",
"printWidth": 180
}
},
{
"files": "Dockerfile",
"options": {
"useTabs": true,
"tabWidth": 4,
"semi": false,
"trailingComma": "none",
"printWidth": 120
}
}
]
}
+38
View File
@@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/routes/layout.css",
"baseColor": "zinc"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
}
+1
View File
@@ -0,0 +1 @@
database folder
+45
View File
@@ -0,0 +1,45 @@
import dotenv from "dotenv";
dotenv.config();
const databaseURL = process.env.DATABASE_URL || "sqlite://./database/kener.sqlite.db";
const databaseURLParts = databaseURL.split("://");
const databaseType = databaseURLParts[0];
const databasePath = databaseURLParts[1];
interface KnexConfig {
migrations: { directory: string };
seeds: { directory: string };
databaseType: string;
client?: string;
connection?: string | { filename: string };
useNullAsDefault?: boolean;
}
const knexOb: KnexConfig = {
migrations: {
directory: "./migrations",
},
seeds: {
directory: "./seeds",
},
databaseType,
};
if (databaseType === "sqlite") {
knexOb.client = "better-sqlite3";
knexOb.connection = {
filename: databasePath,
};
knexOb.useNullAsDefault = true;
} else if (databaseType === "postgresql") {
knexOb.client = "pg";
knexOb.connection = databaseURL;
} else if (databaseType === "mysql") {
knexOb.client = "mysql2";
knexOb.connection = databaseURL;
} else {
console.error("Invalid database type");
process.exit(1);
}
export default knexOb;
+135
View File
@@ -0,0 +1,135 @@
// import { handler } from "./build/handler.js";
import { apiReference } from "@scalar/express-api-reference";
import dotenv from "dotenv";
dotenv.config();
import express from "express";
import Startup from "./src/lib/server/startup.ts";
import shutdownSchedulers from "./src/lib/server/schedulers/shutdown.ts";
import shutdownQueues from "./src/lib/server/queues/shutdown.ts";
import dbInstance from "./src/lib/server/db/db.ts";
import fs from "fs-extra";
import knex from "knex";
import knexOb from "./knexfile.js";
const PORT = process.env.PORT || 3000;
const base = process.env.KENER_BASE_PATH || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const app: any = express();
const db = knex(knexOb);
if (process.env.ORIGIN) {
process.env.SVELTEKIT_ORIGIN = process.env.ORIGIN;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app.use((req: any, res: any, next: any) => {
if (req.path.startsWith("/embed")) {
res.setHeader("Content-Security-Policy", "frame-ancestors *");
}
res.setHeader("X-Powered-By", "Kener");
next();
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app.get(base + "/healthcheck", (req: any, res: any) => {
res.end("ok");
});
//part /uploads server static files from static/uploads
//set env variable for upload path
process.env.UPLOAD_PATH = "./uploads";
app.use(base + "/uploads", express.static("uploads"));
try {
const openapiJSON = fs.readFileSync("./openapi.json", "utf-8");
app.use(
"/api-reference",
apiReference({
spec: {
content: openapiJSON,
},
theme: "alternate",
hideModels: true,
hideTestRequestButton: true,
darkMode: true,
metaData: {
title: "Kener API Reference",
description: "Kener free open source status page API Reference",
ogDescription: "Kener free open source status page API Reference",
ogTitle: "Kener API Reference",
ogImage: "https://kener.ing/newbg.png",
twitterCard: "summary_large_image",
twitterTitle: "Kener API Reference",
twitterDescription: "Kener free open source status page API Reference",
twitterImage: "https://kener.ing/newbg.png",
},
favicon: "https://kener.ing/logo96.png",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any),
);
} catch (e) {
console.warn("Error loading openapi.json, but that is okay.");
}
// app.use(handler);
//migrations
async function runMigrations() {
try {
console.log("Running migrations...");
await db.migrate.latest(); // Runs migrations to the latest state
console.log("Migrations completed successfully!");
} catch (err) {
console.error("Error running migrations:", err);
}
}
//seed
async function runSeed() {
try {
console.log("Running seed...");
await db.seed.run(); // Runs seed to the latest state
console.log("Seed completed successfully!");
} catch (err) {
console.error("Error running seed:", err);
}
}
app.listen(PORT, async () => {
await runMigrations();
await runSeed();
await db.destroy();
Startup();
console.log("Kener is running on port " + PORT + "!");
});
// Graceful shutdown handler
async function gracefulShutdown(signal: string) {
console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
try {
console.log("Shutting down schedulers...");
await shutdownSchedulers();
console.log("Schedulers shut down successfully.");
console.log("Shutting down queues...");
await shutdownQueues();
console.log("Queues shut down successfully.");
console.log("Closing database connection...");
await dbInstance.close();
console.log("Database connection closed successfully.");
console.log("Graceful shutdown completed.");
process.exit(0);
} catch (err) {
console.error("Error during graceful shutdown:", err);
process.exit(1);
}
}
// Handle termination signals
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
+135
View File
@@ -0,0 +1,135 @@
// migrations/YYYYMMDDHHMMSS_create_monitoring_tables.js
export function up(knex) {
return (
knex.schema
// Create monitoring_data table
.createTable("monitoring_data", (table) => {
table.string("monitor_tag", 255).notNullable();
table.integer("timestamp").notNullable();
table.text("status");
table.float("latency", 8, 2);
table.text("type");
table.primary(["monitor_tag", "timestamp"]);
})
// Create monitor_alerts table
.createTable("monitor_alerts", (table) => {
table.increments("id").primary();
table.string("monitor_tag", 255).notNullable();
table.string("monitor_status", 255).notNullable();
table.string("alert_status", 255).notNullable();
table.integer("health_checks").notNullable();
table.integer("incident_number").defaultTo(0);
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
})
// Add index to monitor_alerts table
.raw(
"CREATE INDEX idx_monitor_tag_created_at ON monitor_alerts (monitor_tag, created_at)"
)
.createTable("site_data", (table) => {
table.increments("id").primary();
table.string("key", 255).notNullable().unique();
table.text("value").notNullable();
table.string("data_type", 255).notNullable();
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
})
.createTable("monitors", (table) => {
table.increments("id").primary();
table.string("tag", 255).notNullable().unique();
table.string("name", 255).notNullable().unique();
table.text("description");
table.text("image");
table.string("cron", 255);
table.string("default_status", 255);
table.string("status", 255);
table.string("category_name", 255);
table.string("monitor_type", 255);
table.string("down_trigger", 255);
table.string("degraded_trigger", 255);
table.text("type_data");
table.integer("day_degraded_minimum_count");
table.integer("day_down_minimum_count");
table.string("include_degraded_in_downtime", 255).defaultTo("NO");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
})
.createTable("triggers", (table) => {
table.increments("id").primary();
table.string("name", 255).notNullable().unique();
table.string("trigger_type", 255);
table.text("trigger_desc");
table.string("trigger_status", 255);
table.text("trigger_meta");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
})
.createTable("users", (table) => {
table.increments("id").primary();
table.string("email", 255).notNullable().unique();
table.string("name", 255).notNullable();
table.string("password_hash", 255).notNullable();
table.integer("is_active").defaultTo(1);
table.integer("is_verified").defaultTo(0);
table.string("role", 255).defaultTo("user");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
})
.createTable("api_keys", (table) => {
table.increments("id").primary();
table.string("name", 255).notNullable().unique();
table.string("hashed_key", 255).notNullable().unique();
table.string("masked_key", 255).notNullable();
table.string("status", 255).defaultTo("ACTIVE");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
})
.createTable("incidents", (table) => {
table.increments("id").primary();
table.string("title", 255).notNullable();
table.integer("start_date_time").notNullable();
table.integer("end_date_time");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
table.string("status", 255).defaultTo("ACTIVE");
table.string("state", 255).defaultTo("INVESTIGATING");
})
.createTable("incident_monitors", (table) => {
table.increments("id").primary();
table.string("monitor_tag", 255).notNullable();
table.string("monitor_impact", 255);
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
table.integer("incident_id").notNullable();
table.unique(["monitor_tag", "incident_id"]);
})
.createTable("incident_comments", (table) => {
table.increments("id").primary();
table.text("comment").notNullable();
table.integer("incident_id").notNullable();
table.integer("commented_at").notNullable();
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
table.string("status", 255).defaultTo("ACTIVE");
table.string("state", 255).defaultTo("INVESTIGATING");
})
);
}
export function down(knex) {
return (
knex.schema
// Drop tables in reverse order
.dropTableIfExists("monitor_alerts")
.dropTableIfExists("monitoring_data")
.dropTableIfExists("site_data")
.dropTableIfExists("monitors")
.dropTableIfExists("triggers")
.dropTableIfExists("users")
.dropTableIfExists("api_keys")
.dropTableIfExists("incidents")
.dropTableIfExists("incident_monitors")
.dropTableIfExists("incident_comments")
);
}
@@ -0,0 +1,11 @@
export function up(knex) {
return knex.schema.alterTable("incidents", function (table) {
table.text("incident_type").defaultTo("INCIDENT");
});
}
export function down(knex) {
return knex.schema.alterTable("incidents", function (table) {
table.dropColumn("incident_type");
});
}
@@ -0,0 +1,11 @@
export function up(knex) {
return knex.schema.alterTable("incidents", function (table) {
table.text("incident_source").defaultTo("DASHBOARD");
});
}
export function down(knex) {
return knex.schema.alterTable("incidents", function (table) {
table.dropColumn("incident_source");
});
}
@@ -0,0 +1,38 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export function up(knex) {
return knex.schema.createTable("invitations", (table) => {
// Primary key
table.increments("id").primary();
// Core invitation fields
table.string("invitation_token").unique().notNullable();
table.string("invitation_type").notNullable();
table.integer("invited_user_id").nullable();
table.integer("invited_by_user_id").notNullable();
// Additional data fields
table.text("invitation_meta").nullable(); // For storing JSON or other metadata
table.timestamp("invitation_expiry").notNullable();
table.string("invitation_status").notNullable().defaultTo("PENDING");
// Timestamps
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Indexes
table.index("invitation_status");
table.index("invitation_expiry");
table.index(["invited_by_user_id", "invitation_status"]);
});
}
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export function down(knex) {
return knex.schema.dropTableIfExists("invitations");
}
@@ -0,0 +1,49 @@
export function up(knex) {
return knex.schema
.createTable("subscribers", (table) => {
table.increments("id").primary();
table.string("subscriber_send").notNullable();
table.text("subscriber_meta").nullable();
table.string("subscriber_type").notNullable();
table.string("subscriber_status").notNullable();
table.datetime("created_at").defaultTo(knex.fn.now());
table.datetime("updated_at").defaultTo(knex.fn.now());
// Add unique constraint on subscriber_send and subscriber_type
table.unique(["subscriber_send", "subscriber_type"]);
// Add index on subscriber_send for better query performance
table.index(["subscriber_send"]);
})
.createTable("subscriptions", (table) => {
table.increments("id").primary();
table.integer("subscriber_id").unsigned().notNullable();
table.string("subscriptions_status").notNullable();
table.string("subscriptions_monitors").notNullable();
table.text("subscriptions_meta").nullable();
table.datetime("created_at").defaultTo(knex.fn.now());
table.datetime("updated_at").defaultTo(knex.fn.now());
// Add unique constraint on subscriber_id and subscriptions_monitors
// This constraint also creates an index that will be used for queries
table.unique(["subscriber_id", "subscriptions_monitors"]);
// Add index to optimize queries filtering by status and monitors
table.index(["subscriptions_status", "subscriptions_monitors"]);
})
.createTable("subscription_triggers", (table) => {
table.increments("id").primary();
table.string("subscription_trigger_type").notNullable().unique();
table.string("subscription_trigger_status").notNullable();
table.text("config").nullable();
table.datetime("created_at").defaultTo(knex.fn.now());
table.datetime("updated_at").defaultTo(knex.fn.now());
});
}
export function down(knex) {
return knex.schema
.dropTableIfExists("subscription_triggers")
.dropTableIfExists("subscriptions")
.dropTableIfExists("subscribers");
}
@@ -0,0 +1,186 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// monitoring_data indexes
// For queries: WHERE monitor_tag = ? AND type = ? ORDER BY timestamp DESC (getLastHeartbeat)
await knex.schema.alterTable("monitoring_data", (table) => {
table.index(["monitor_tag", "type", "timestamp"], "idx_monitoring_data_tag_type_ts");
});
// For background cleanup: WHERE timestamp < ?
await knex.schema.alterTable("monitoring_data", (table) => {
table.index(["timestamp"], "idx_monitoring_data_timestamp");
});
// monitor_alerts indexes
// For queries: WHERE incident_number = ?
await knex.schema.alterTable("monitor_alerts", (table) => {
table.index(["incident_number"], "idx_monitor_alerts_incident_number");
});
// For queries: WHERE monitor_tag = ? AND monitor_status = ? AND alert_status = ?
await knex.schema.alterTable("monitor_alerts", (table) => {
table.index(["monitor_tag", "monitor_status", "alert_status"], "idx_monitor_alerts_tag_status_alert");
});
// monitors indexes
// For queries: WHERE status = ?
await knex.schema.alterTable("monitors", (table) => {
table.index(["status"], "idx_monitors_status");
});
// For queries: WHERE category_name = ?
await knex.schema.alterTable("monitors", (table) => {
table.index(["category_name"], "idx_monitors_category");
});
// incidents indexes
// For queries: WHERE status = ? ORDER BY start_date_time
await knex.schema.alterTable("incidents", (table) => {
table.index(["status", "start_date_time"], "idx_incidents_status_start");
});
// For queries: WHERE start_date_time >= ? AND start_date_time <= ?
await knex.schema.alterTable("incidents", (table) => {
table.index(["start_date_time", "end_date_time"], "idx_incidents_start_end");
});
// For queries: ORDER BY updated_at DESC (getRecentUpdatedIncidents)
await knex.schema.alterTable("incidents", (table) => {
table.index(["updated_at"], "idx_incidents_updated_at");
});
// For queries: WHERE state = ?
await knex.schema.alterTable("incidents", (table) => {
table.index(["state"], "idx_incidents_state");
});
// For queries: WHERE incident_type = ?
await knex.schema.alterTable("incidents", (table) => {
table.index(["incident_type"], "idx_incidents_type");
});
// incident_monitors indexes
// For queries: WHERE incident_id = ? (joining with incidents)
await knex.schema.alterTable("incident_monitors", (table) => {
table.index(["incident_id"], "idx_incident_monitors_incident_id");
});
// For queries: WHERE monitor_tag = ? (getIncidentsByMonitorTag)
await knex.schema.alterTable("incident_monitors", (table) => {
table.index(["monitor_tag"], "idx_incident_monitors_monitor_tag");
});
// incident_comments indexes
// For queries: WHERE incident_id = ?
await knex.schema.alterTable("incident_comments", (table) => {
table.index(["incident_id"], "idx_incident_comments_incident_id");
});
// For queries: WHERE incident_id = ? AND status = ?
await knex.schema.alterTable("incident_comments", (table) => {
table.index(["incident_id", "status"], "idx_incident_comments_incident_status");
});
// triggers indexes
// For queries: WHERE trigger_status = ?
await knex.schema.alterTable("triggers", (table) => {
table.index(["trigger_status"], "idx_triggers_status");
});
// subscribers indexes
// For queries: WHERE subscriber_status = ?
await knex.schema.alterTable("subscribers", (table) => {
table.index(["subscriber_status"], "idx_subscribers_status");
});
// For queries: WHERE subscriber_type = ?
await knex.schema.alterTable("subscribers", (table) => {
table.index(["subscriber_type"], "idx_subscribers_type");
});
// invitations indexes
// For queries: WHERE invited_user_id = ? AND invitation_type = ? AND invitation_status = ?
await knex.schema.alterTable("invitations", (table) => {
table.index(["invited_user_id", "invitation_type", "invitation_status"], "idx_invitations_user_type_status");
});
}
export async function down(knex: Knex): Promise<void> {
// Drop indexes in reverse order
await knex.schema.alterTable("invitations", (table) => {
table.dropIndex([], "idx_invitations_user_type_status");
});
await knex.schema.alterTable("subscribers", (table) => {
table.dropIndex([], "idx_subscribers_type");
});
await knex.schema.alterTable("subscribers", (table) => {
table.dropIndex([], "idx_subscribers_status");
});
await knex.schema.alterTable("triggers", (table) => {
table.dropIndex([], "idx_triggers_status");
});
await knex.schema.alterTable("incident_comments", (table) => {
table.dropIndex([], "idx_incident_comments_incident_status");
});
await knex.schema.alterTable("incident_comments", (table) => {
table.dropIndex([], "idx_incident_comments_incident_id");
});
await knex.schema.alterTable("incident_monitors", (table) => {
table.dropIndex([], "idx_incident_monitors_monitor_tag");
});
await knex.schema.alterTable("incident_monitors", (table) => {
table.dropIndex([], "idx_incident_monitors_incident_id");
});
await knex.schema.alterTable("incidents", (table) => {
table.dropIndex([], "idx_incidents_type");
});
await knex.schema.alterTable("incidents", (table) => {
table.dropIndex([], "idx_incidents_state");
});
await knex.schema.alterTable("incidents", (table) => {
table.dropIndex([], "idx_incidents_updated_at");
});
await knex.schema.alterTable("incidents", (table) => {
table.dropIndex([], "idx_incidents_start_end");
});
await knex.schema.alterTable("incidents", (table) => {
table.dropIndex([], "idx_incidents_status_start");
});
await knex.schema.alterTable("monitors", (table) => {
table.dropIndex([], "idx_monitors_category");
});
await knex.schema.alterTable("monitors", (table) => {
table.dropIndex([], "idx_monitors_status");
});
await knex.schema.alterTable("monitor_alerts", (table) => {
table.dropIndex([], "idx_monitor_alerts_tag_status_alert");
});
await knex.schema.alterTable("monitor_alerts", (table) => {
table.dropIndex([], "idx_monitor_alerts_incident_number");
});
await knex.schema.alterTable("monitoring_data", (table) => {
table.dropIndex([], "idx_monitoring_data_timestamp");
});
await knex.schema.alterTable("monitoring_data", (table) => {
table.dropIndex([], "idx_monitoring_data_tag_type_ts");
});
}
@@ -0,0 +1,19 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable("images", (table) => {
table.string("id", 32).primary(); // nanoid generated ID with prefix
table.text("data").notNullable(); // base64 encoded image data
table.string("mime_type", 50).notNullable(); // image/png, image/jpeg, image/svg+xml
table.string("original_name", 255); // original filename
table.integer("width"); // image width after resize
table.integer("height"); // image height after resize
table.integer("size"); // size in bytes
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("images");
}
@@ -0,0 +1,41 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// Create pages table
await knex.schema.createTable("pages", (table) => {
table.increments("id").primary();
table.string("page_path", 255).notNullable().unique(); // e.g., "/", "/api", "/infrastructure"
table.string("page_title", 255).notNullable();
table.string("page_header", 255);
table.string("page_subheader", 255);
table.string("page_logo", 255);
table.text("page_settings_json"); // JSON settings for the page
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
});
// Create pages_monitors junction table
await knex.schema.createTable("pages_monitors", (table) => {
table.integer("page_id").unsigned().notNullable();
table.string("monitor_tag", 255).notNullable();
table.text("monitor_settings_json"); // JSON settings for monitor on this page (e.g., order, visibility)
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Composite primary key
table.primary(["page_id", "monitor_tag"]);
// Foreign key constraints
table.foreign("page_id").references("id").inTable("pages").onDelete("CASCADE");
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
});
// Add index for faster lookups
await knex.schema.raw("CREATE INDEX idx_pages_monitors_page_id ON pages_monitors (page_id)");
await knex.schema.raw("CREATE INDEX idx_pages_monitors_monitor_tag ON pages_monitors (monitor_tag)");
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("pages_monitors");
await knex.schema.dropTableIfExists("pages");
}
@@ -0,0 +1,68 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// Create maintenances table - defines maintenance schedules using iCalendar RRULE
// RRULE examples:
// - ONE_TIME: FREQ=MINUTELY;COUNT=1 (single occurrence)
// - RECURRING: FREQ=WEEKLY;BYDAY=SU;BYHOUR=2;BYMINUTE=0 (every Sunday at 2 AM)
// Reference: http://www.kanzaki.com/docs/ical/rrule.html
await knex.schema.createTable("maintenances", (table) => {
table.increments("id").primary();
table.string("title", 255).notNullable();
table.text("description").nullable(); // Maintenance details/description
table.integer("start_date_time").notNullable(); // Unix timestamp - when the first occurrence starts
table.string("rrule", 500).notNullable(); // iCalendar RRULE string (e.g., FREQ=WEEKLY;BYDAY=SU)
table.integer("duration_seconds").notNullable(); // Duration of each maintenance window in seconds
table.string("status", 50).notNullable().defaultTo("ACTIVE"); // ACTIVE or INACTIVE
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
});
// Create maintenance_monitors junction table - links monitors to maintenance schedules
await knex.schema.createTable("maintenance_monitors", (table) => {
table.increments("id").primary();
table.integer("maintenance_id").unsigned().notNullable();
table.string("monitor_tag", 255).notNullable();
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Foreign key constraints
table.foreign("maintenance_id").references("id").inTable("maintenances").onDelete("CASCADE");
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
// Unique constraint to prevent duplicate monitor assignments
table.unique(["maintenance_id", "monitor_tag"]);
});
// Create maintenances_events table - actual maintenance occurrences (generated by job)
await knex.schema.createTable("maintenances_events", (table) => {
table.increments("id").primary();
table.integer("maintenance_id").unsigned().notNullable();
table.integer("start_date_time").notNullable(); // Unix timestamp
table.integer("end_date_time").notNullable(); // Unix timestamp
table.string("status", 50).notNullable().defaultTo("SCHEDULED"); // SCHEDULED, IN_PROGRESS, COMPLETED, CANCELLED
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Foreign key constraint
table.foreign("maintenance_id").references("id").inTable("maintenances").onDelete("CASCADE");
});
// Add indexes for faster lookups
await knex.schema.raw("CREATE INDEX idx_maintenances_status ON maintenances (status)");
await knex.schema.raw("CREATE INDEX idx_maintenances_start_time ON maintenances (start_date_time)");
await knex.schema.raw(
"CREATE INDEX idx_maintenance_monitors_maintenance_id ON maintenance_monitors (maintenance_id)",
);
await knex.schema.raw("CREATE INDEX idx_maintenance_monitors_monitor_tag ON maintenance_monitors (monitor_tag)");
await knex.schema.raw("CREATE INDEX idx_maintenances_events_maintenance_id ON maintenances_events (maintenance_id)");
await knex.schema.raw("CREATE INDEX idx_maintenances_events_status ON maintenances_events (status)");
await knex.schema.raw("CREATE INDEX idx_maintenances_events_start_time ON maintenances_events (start_date_time)");
await knex.schema.raw("CREATE INDEX idx_maintenances_events_end_time ON maintenances_events (end_date_time)");
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("maintenances_events");
await knex.schema.dropTableIfExists("maintenance_monitors");
await knex.schema.dropTableIfExists("maintenances");
}
@@ -0,0 +1,15 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// Remove unique constraint from monitors.name
await knex.schema.alterTable("monitors", (table) => {
table.dropUnique(["name"]);
});
}
export async function down(knex: Knex): Promise<void> {
// Re-add unique constraint to monitors.name
await knex.schema.alterTable("monitors", (table) => {
table.unique(["name"]);
});
}
@@ -0,0 +1,13 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("monitors", (table) => {
table.string("is_hidden").defaultTo("NO").notNullable();
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable("monitors", (table) => {
table.dropColumn("is_hidden");
});
}
@@ -0,0 +1,13 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("monitors", (table) => {
table.text("monitor_settings_json").nullable();
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable("monitors", (table) => {
table.dropColumn("monitor_settings_json");
});
}
+13555
View File
File diff suppressed because it is too large Load Diff
+167
View File
@@ -0,0 +1,167 @@
{
"name": "kener",
"version": "4.0.0",
"type": "module",
"private": false,
"license": "MIT",
"description": "Kener is a modern, open-source status page application built with Node.js. It provides real-time monitoring, uptime tracking, incident management, and beautiful dashboards. Perfect for DevOps teams, SaaS providers, and businesses needing reliable service status communication with minimal setup.",
"author": "Raj Nandan Sharma <rajnandan1@gmail.com>",
"keywords": [
"status page",
"uptime monitoring",
"incident management",
"DevOps tools",
"service reliability",
"open source",
"Node.js",
"dashboard",
"system monitoring",
"status alerts",
"outage communication",
"API monitoring",
"SaaS status",
"performance metrics",
"status reporting"
],
"repository": {
"type": "git",
"url": "https://github.com/rajnandan1/kener.git"
},
"scripts": {
"build": "vite build",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"configure": "node build.js",
"dev": "npm-run-all --parallel devschedule development",
"development": "vite dev",
"devschedule": "npx tsx src/lib/server/startup.ts",
"generate-readme": "node scripts/generate-readme.js",
"migrate": "npx knex migrate:latest",
"predev": "npm run seed",
"prepare": "svelte-kit sync || echo ''",
"preseed": "npx knex migrate:latest",
"prettify": "prettier --write .",
"preview": "vite preview",
"schedule": "npx tsx src/lib/server/startup.ts",
"seed": "npx knex seed:run",
"start": "npx tsx main.ts"
},
"devDependencies": {
"@internationalized/date": "^3.10.0",
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.48.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@types/bcrypt": "^6.0.0",
"@types/d3-scale": "^4.0.9",
"@types/d3-shape": "^3.1.8",
"@types/dns2": "^2.0.10",
"@types/express": "^5.0.6",
"@types/fs-extra": "^11.0.4",
"@types/jsonwebtoken": "^9.0.10",
"@types/mustache": "^4.2.6",
"@types/node": "^25.0.3",
"@types/nodemailer": "^7.0.4",
"@zerodevx/svelte-toast": "^0.9.6",
"autoprefixer": "^10.4.22",
"clsx": "^2.1.1",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"date-picker-svelte": "^2.17.0",
"layerchart": "^2.0.0-next.43",
"postcss": "^8.5.6",
"postcss-load-config": "^6.0.1",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.43.8",
"svelte-awesome-color-picker": "^4.1.0",
"svelte-check": "^4.3.4",
"svelte-dnd-action": "^0.9.68",
"svelte-sonner": "^1.0.7",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.2.2",
"vite-plugin-devtools-json": "^1.0.0"
},
"engines": {
"node": ">=20.0.0"
},
"dependencies": {
"@babel/runtime": "^7.28.4",
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.10.1",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/language": "^6.12.1",
"@codemirror/lint": "^6.9.2",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.39.11",
"@formkit/auto-animate": "^0.9.0",
"@number-flow/svelte": "^0.3.9",
"@openai/agents": "^0.3.7",
"@rajnandan1/aven": "file:../aven",
"@rajnandan1/svelte-legos": "^0.0.1",
"@scalar/express-api-reference": "^0.8.28",
"@uiw/codemirror-theme-github": "^4.25.3",
"analytics": "^0.8.19",
"axios": "^1.13.2",
"badge-maker": "^5.0.2",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.5.0",
"bits-ui": "^2.14.4",
"bullmq": "^5.66.2",
"cheerio": "^1.1.2",
"croner": "^9.1.0",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"dns2": "^2.1.0",
"dotenv": "^17.2.3",
"esbuild": "^0.27.2",
"express": "^5.2.1",
"figlet": "^1.9.4",
"fs-extra": "^11.3.2",
"gamedig": "^5.3.2",
"highlight.js": "^11.11.1",
"ioredis": "^5.8.2",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.3",
"knex": "^3.1.0",
"lucide-svelte": "^0.561.0",
"marked": "^17.0.1",
"marked-highlight": "^2.2.3",
"mobile-detect": "^1.4.5",
"mode-watcher": "^1.1.0",
"moment": "^2.30.1",
"moment-timezone": "^0.6.0",
"mustache": "^4.2.0",
"mysql2": "^3.15.3",
"nanoid": "^5.1.6",
"node-cache": "^5.1.2",
"nodemailer": "^7.0.11",
"npm-run-all": "^4.1.5",
"pg": "^8.16.3",
"pg-pool": "^3.10.1",
"ping": "^1.0.0",
"queue": "^7.0.0",
"randomstring": "^1.3.1",
"resend": "^6.6.0",
"rrule": "^2.8.1",
"sharp": "^0.34.5",
"style-to-object": "^1.0.14",
"svelte-codemirror-editor": "^2.1.0",
"vite-plugin-package-version": "^1.1.0",
"zod": "^4.3.2"
}
}
+29
View File
@@ -0,0 +1,29 @@
import monitorSeed from "../src/lib/server/db/seedMonitorData.ts";
import type { Knex } from "knex";
export async function seed(knex: Knex): Promise<void> {
// Check if the table is empty
const count = await knex("monitors").count("id as CNT").first();
if (count && count.CNT == 0) {
// Deletes ALL existing entries
for (const monitor of monitorSeed) {
await knex("monitors").insert({
tag: monitor.tag,
name: monitor.name,
description: monitor.description,
image: monitor.image,
cron: monitor.cron,
default_status: monitor.default_status,
status: monitor.status,
category_name: monitor.category_name,
monitor_type: monitor.monitor_type,
type_data: monitor.type_data,
day_degraded_minimum_count: monitor.day_degraded_minimum_count,
day_down_minimum_count: monitor.day_down_minimum_count,
include_degraded_in_downtime: monitor.include_degraded_in_downtime,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
}
}
+52
View File
@@ -0,0 +1,52 @@
import seedPagesData from "../src/lib/server/db/seedPagesData.ts";
import type { Knex } from "knex";
export async function seed(knex: Knex): Promise<void> {
// Check if the pages table is empty
const pageCount = await knex("pages").count("id as CNT").first();
if (pageCount && pageCount.CNT == 0) {
// Insert seed pages
for (const page of seedPagesData) {
const [insertedPage] = await knex("pages")
.insert({
page_path: page.page_path,
page_title: page.page_title,
page_header: page.page_header,
page_subheader: page.page_subheader,
page_logo: page.page_logo,
page_settings_json: page.page_settings_json,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
})
.returning("id");
// For the home page, add the default monitor (earth) if it exists
if (page.page_path === "/") {
const earthMonitor = await knex("monitors").where({ tag: "earth" }).first();
if (earthMonitor) {
const pageId = typeof insertedPage === "object" ? insertedPage.id : insertedPage;
await knex("pages_monitors").insert({
page_id: pageId,
monitor_tag: "earth",
monitor_settings_json: "",
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
const kenerMonitor = await knex("monitors").where({ tag: "kener" }).first();
if (kenerMonitor) {
const pageId = typeof insertedPage === "object" ? insertedPage.id : insertedPage;
await knex("pages_monitors").insert({
page_id: pageId,
monitor_tag: "kener",
monitor_settings_json: "",
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
}
}
}
}
+22
View File
@@ -0,0 +1,22 @@
import seedSiteData from "../src/lib/server/db/seedSiteData.ts";
import type { Knex } from "knex";
export async function seed(knex: Knex): Promise<void> {
// Check if the table is empty
const count = await knex("site_data").count("id as CNT").first();
const seedDataRecord = seedSiteData as Record<string, unknown>;
for (const key in seedDataRecord) {
if (Object.prototype.hasOwnProperty.call(seedDataRecord, key)) {
let value = seedDataRecord[key];
let data_type = typeof value;
if (data_type === "object") {
value = JSON.stringify(value);
}
const existingEntry = await knex("site_data").where({ key: key }).first();
if (!existingEntry) {
await knex("site_data").insert([{ key: key, value: value, data_type: data_type }]);
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
// Example: set by hooks.server.ts after validating a cookie/JWT.
user?: import("$lib/server/types/auth").SessionUser;
}
interface PageData {
// Example: anything you return from load functions.
currentUser?: import("$lib/server/types/auth").SessionUser;
}
// interface PageState {}
// interface Platform {}
}
}
export {};
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
const response = await resolve(event);
response.headers.delete("Link");
return response;
}
+116
View File
@@ -0,0 +1,116 @@
<script lang="ts">
import { onMount, onDestroy, beforeUpdate } from "svelte";
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
export let monitor: string;
export let theme: string = "light";
export let bgc: string = "transparent";
export let locale: string = "en";
interface MessageData {
height?: string;
width?: string;
}
let iframe: HTMLIFrameElement | null = null;
let listeners: ((event: MessageEvent<MessageData>) => void) | null = null;
let containerId = `embed-container-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
let isMounted = false;
let prevMonitor: string | undefined;
let prevTheme: string | undefined;
let prevBgc: string | undefined;
let prevLocale: string | undefined;
onMount(() => {
if (!monitor) return;
isMounted = true;
prevMonitor = monitor;
prevTheme = theme;
prevBgc = bgc;
prevLocale = locale;
const container = document.getElementById(containerId);
if (!container) return;
const uid = "KENER_" + ~~(new Date().getTime() / 86400000);
const uriEmbedded = `${monitor}?theme=${theme}&bgc=${bgc}&locale=${locale}`;
iframe = document.createElement("iframe");
iframe.src = uriEmbedded;
iframe.id = uid;
iframe.width = "0%";
iframe.height = "0";
iframe.frameBorder = "0";
(iframe as HTMLIFrameElement & { allowTransparency: boolean }).allowTransparency = true;
iframe.sandbox.add(
"allow-modals",
"allow-forms",
"allow-same-origin",
"allow-scripts",
"allow-popups",
"allow-top-navigation-by-user-activation",
"allow-downloads"
);
iframe.allow = "midi; geolocation; microphone; camera; display-capture; encrypted-media;";
container.appendChild(iframe);
const setHeight = (data: MessageData) => {
if (data.height !== undefined && iframe) {
iframe.height = data.height;
}
};
const setWidth = (data: MessageData) => {
if (data.width !== undefined && iframe) {
iframe.width = data.width;
}
};
listeners = (event: MessageEvent<MessageData>) => {
if (event.data && event.data.height !== undefined) {
setHeight(event.data);
}
if (event.data && event.data.width !== undefined) {
setWidth(event.data);
}
};
window.addEventListener("message", listeners, false);
});
beforeUpdate(() => {
if (
isMounted &&
iframe &&
(monitor !== prevMonitor || theme !== prevTheme || bgc !== prevBgc || locale !== prevLocale)
) {
iframe.src = `${monitor}?theme=${theme}&bgc=${bgc}&locale=${locale}`;
// Update previous values
prevMonitor = monitor;
prevTheme = theme;
prevBgc = bgc;
prevLocale = locale;
dispatch("update", {
monitor: prevMonitor,
theme: prevTheme,
bgc: prevBgc,
locale: prevLocale
});
}
});
onDestroy(() => {
if (iframe && listeners) {
window.removeEventListener("message", listeners);
iframe.remove();
}
});
</script>
<!-- Dynamic container with unique ID -->
<div id={containerId}></div>
+356
View File
@@ -0,0 +1,356 @@
[
{ "id": "sdtd", "name": "7 Days to Die", "isValve": true },
{ "id": "arma2", "name": "ARMA 2", "isValve": true },
{ "id": "a2oa", "name": "ARMA 2: Operation Arrowhead", "isValve": true },
{ "id": "arma3", "name": "ARMA 3", "isValve": true },
{ "id": "aaa", "name": "ARMA: Armed Assault" },
{ "id": "acwa", "name": "ARMA: Cold War Assault" },
{ "id": "armareforger", "name": "ARMA: Reforger", "isValve": true },
{ "id": "armaresistance", "name": "ARMA: Resistance" },
{ "id": "abioticfactor", "name": "Abiotic Factor", "isValve": true },
{ "id": "aosc", "name": "Ace of Spades Classic" },
{ "id": "ahl", "name": "Action Half-Life", "isValve": true },
{ "id": "actionsource", "name": "Action: Source", "isValve": true },
{ "id": "aoc", "name": "Age of Chivalry", "isValve": true },
{ "id": "aoe2", "name": "Age of Empires 2" },
{ "id": "alienarena", "name": "Alien Arena" },
{ "id": "alienswarm", "name": "Alien Swarm", "isValve": true },
{ "id": "avp2", "name": "Aliens versus Predator 2" },
{ "id": "avp2010", "name": "Aliens vs. Predator 2010", "isValve": true },
{ "id": "americasarmy", "name": "America's Army" },
{ "id": "americasarmy2", "name": "America's Army 2" },
{ "id": "americasarmy3", "name": "America's Army 3", "isValve": true },
{ "id": "aapg", "name": "America's Army: Proving Grounds", "isValve": true },
{ "id": "asr08", "name": "Arca Sim Racing '08" },
{ "id": "asa", "name": "Ark: Survival Ascended" },
{ "id": "ase", "name": "Ark: Survival Evolved", "isValve": true },
{ "id": "armagetronadvanced", "name": "Armagetron Advanced" },
{ "id": "assettocorsa", "name": "Assetto Corsa" },
{ "id": "atlas", "name": "Atlas", "isValve": true },
{ "id": "avorion", "name": "Avorion", "isValve": true },
{ "id": "brokeprotocol", "name": "BROKE PROTOCOL" },
{ "id": "baldursgate", "name": "Baldur's Gate" },
{ "id": "ballisticoverkill", "name": "Ballistic Overkill", "isValve": true },
{ "id": "barotrauma", "name": "Barotrauma", "isValve": true },
{ "id": "basedefense", "name": "Base Defense", "isValve": true },
{ "id": "battalion1944", "name": "Battalion 1944", "isValve": true },
{ "id": "battlefield1942", "name": "Battlefield 1942" },
{ "id": "battlefield2", "name": "Battlefield 2" },
{ "id": "battlefield2142", "name": "Battlefield 2142" },
{ "id": "battlefield3", "name": "Battlefield 3" },
{ "id": "battlefield4", "name": "Battlefield 4" },
{ "id": "battlefieldhardline", "name": "Battlefield Hardline" },
{ "id": "battlefieldvietnam", "name": "Battlefield Vietnam" },
{ "id": "bbc2", "name": "Battlefield: Bad Company 2" },
{ "id": "beammp", "name": "BeamMP (2021)" },
{ "id": "blackmesa", "name": "Black Mesa", "isValve": true },
{ "id": "bladesymphony", "name": "Blade Symphony", "isValve": true },
{ "id": "brainbread", "name": "BrainBread", "isValve": true },
{ "id": "brainbread2", "name": "BrainBread 2", "isValve": true },
{ "id": "breach", "name": "Breach", "isValve": true },
{ "id": "breed", "name": "Breed" },
{ "id": "brink", "name": "Brink", "isValve": true },
{ "id": "bas", "name": "Build and Shoot" },
{ "id": "c2d", "name": "CS2D" },
{ "id": "cod", "name": "Call of Duty" },
{ "id": "cod2", "name": "Call of Duty 2" },
{ "id": "cod3", "name": "Call of Duty 3" },
{ "id": "cod4mw", "name": "Call of Duty 4: Modern Warfare" },
{ "id": "codbo3", "name": "Call of Duty: Black Ops 3", "isValve": true },
{ "id": "codmw2", "name": "Call of Duty: Modern Warfare 2" },
{ "id": "codmw3", "name": "Call of Duty: Modern Warfare 3", "isValve": true },
{ "id": "coduo", "name": "Call of Duty: United Offensive" },
{ "id": "codwaw", "name": "Call of Duty: World at War" },
{ "id": "coj", "name": "Call of Juarez" },
{ "id": "chaser", "name": "Chaser" },
{ "id": "cmw", "name": "Chivalry: Medieval Warfare", "isValve": true },
{ "id": "chrome", "name": "Chrome" },
{ "id": "codenamecure", "name": "Codename CURE", "isValve": true },
{ "id": "codenameeagle", "name": "Codename Eagle" },
{ "id": "colonysurvival", "name": "Colony Survival", "isValve": true },
{ "id": "cacr", "name": "Command and Conquer: Renegade" },
{ "id": "c3db", "name": "Commandos 3: Destination Berlin" },
{ "id": "conanexiles", "name": "Conan Exiles", "isValve": true },
{ "id": "contagion", "name": "Contagion", "isValve": true },
{ "id": "contractjack", "name": "Contract J.A.C.K." },
{ "id": "corekeeper", "name": "Core Keeper", "isValve": true },
{ "id": "counterstrike15", "name": "Counter-Strike 1.5" },
{ "id": "counterstrike16", "name": "Counter-Strike 1.6", "isValve": true },
{ "id": "counterstrike2", "name": "Counter-Strike 2", "isValve": true },
{ "id": "cscz", "name": "Counter-Strike: Condition Zero", "isValve": true },
{ "id": "csgo", "name": "Counter-Strike: Global Offensive", "isValve": true },
{ "id": "css", "name": "Counter-Strike: Source", "isValve": true },
{ "id": "creativerse", "name": "Creativerse", "isValve": true },
{ "id": "crce", "name": "Cross Racing Championship Extreme" },
{ "id": "crysis", "name": "Crysis" },
{ "id": "crysis2", "name": "Crysis 2" },
{ "id": "crysiswars", "name": "Crysis Wars" },
{ "id": "daikatana", "name": "Daikatana" },
{ "id": "dmomam", "name": "Dark Messiah of Might and Magic", "isValve": true },
{ "id": "dal", "name": "Dark and Light", "isValve": true },
{ "id": "dhe4445", "name": "Darkest Hour: Europe '44-'45" },
{ "id": "dod", "name": "Day of Defeat", "isValve": true },
{ "id": "dods", "name": "Day of Defeat: Source", "isValve": true },
{ "id": "dayofdragons", "name": "Day of Dragons", "isValve": true },
{ "id": "doi", "name": "Day of Infamy", "isValve": true },
{ "id": "dayz", "name": "DayZ", "isValve": true },
{ "id": "dayzmod", "name": "DayZ Mod", "isValve": true },
{ "id": "dow", "name": "Days of War", "isValve": true },
{ "id": "ddpt", "name": "Deadly Dozen: Pacific Theater" },
{ "id": "deathmatchclassic", "name": "Deathmatch Classic", "isValve": true },
{ "id": "deerhunter2005", "name": "Deer Hunter 2005" },
{ "id": "descent3", "name": "Descent 3" },
{ "id": "deusex", "name": "Deus Ex" },
{ "id": "devastation", "name": "Devastation" },
{ "id": "ddd", "name": "Dino D-Day", "isValve": true },
{ "id": "dtr2", "name": "Dirt Track Racing 2" },
{ "id": "discord", "name": "Discord" },
{ "id": "dst", "name": "Don't Starve Together", "isValve": true },
{ "id": "doom3", "name": "Doom 3" },
{ "id": "dota2", "name": "Dota 2", "isValve": true },
{ "id": "dab", "name": "Double Action: Boogaloo", "isValve": true },
{ "id": "dootf", "name": "Drakan: Order of the Flame" },
{ "id": "dnf2001", "name": "Duke Nukem Forever 2001" },
{ "id": "dystopia", "name": "Dystopia", "isValve": true },
{ "id": "eco", "name": "Eco" },
{ "id": "empiresmod", "name": "Empires Mod", "isValve": true },
{ "id": "egs", "name": "Empyrion - Galactic Survival", "isValve": true },
{ "id": "etqw", "name": "Enemy Territory: Quake Wars" },
{ "id": "ets2", "name": "Euro Truck Simulator 2", "isValve": true },
{ "id": "exfil", "name": "Exfil", "isValve": true },
{ "id": "fear", "name": "F.E.A.R." },
{ "id": "f1c9902", "name": "F1 Challenge '99-'02" },
{ "id": "foundry", "name": "FOUNDRY", "isValve": true },
{ "id": "factorio", "name": "Factorio" },
{ "id": "farcry", "name": "Far Cry" },
{ "id": "farcry2", "name": "Far Cry 2" },
{ "id": "farmingsimulator19", "name": "Farming Simulator 19" },
{ "id": "farmingsimulator22", "name": "Farming Simulator 22" },
{ "id": "farmingsimulator25", "name": "Farming Simulator 25" },
{ "id": "fof", "name": "Fistful of Frags", "isValve": true },
{ "id": "formulaone2002", "name": "Formula One 2002" },
{ "id": "fortressforever", "name": "Fortress Forever", "isValve": true },
{ "id": "ffow", "name": "Frontlines: Fuel of War" },
{ "id": "garrysmod", "name": "Garry's Mod", "isValve": true },
{ "id": "geneshift", "name": "Geneshift" },
{ "id": "gck", "name": "Giants: Citizen Kabuto" },
{ "id": "globaloperations", "name": "Global Operations" },
{ "id": "goldeneyesource", "name": "GoldenEye: Source", "isValve": true },
{ "id": "gus", "name": "Gore: Ultimate Soldier" },
{ "id": "gta5f", "name": "Grand Theft Auto V - FiveM" },
{ "id": "gta5r", "name": "Grand Theft Auto V - RageMP" },
{ "id": "gta5am", "name": "Grand Theft Auto V - alt:V Multiplayer" },
{ "id": "gtasamta", "name": "Grand Theft Auto: San Andreas - Multi Theft Auto" },
{ "id": "gtasam", "name": "Grand Theft Auto: San Andreas Multiplayer" },
{ "id": "gtasao", "name": "Grand Theft Auto: San Andreas OpenMP" },
{ "id": "gtavcmta", "name": "Grand Theft Auto: Vice City - Multi Theft Auto" },
{ "id": "groundbreach", "name": "Ground Breach", "isValve": true },
{ "id": "gunmanchronicles", "name": "Gunman Chronicles", "isValve": true },
{ "id": "hl2d", "name": "Half-Life 2: Deathmatch", "isValve": true },
{ "id": "hld", "name": "Half-Life Deathmatch", "isValve": true },
{ "id": "hlds", "name": "Half-Life Deathmatch: Source", "isValve": true },
{ "id": "hlof", "name": "Half-Life: Opposing Force", "isValve": true },
{ "id": "halo", "name": "Halo" },
{ "id": "halo2", "name": "Halo 2" },
{ "id": "eldewrito", "name": "Halo Online - ElDewrito" },
{ "id": "hawakening", "name": "Hawakening" },
{ "id": "hll", "name": "Hell Let Loose", "isValve": true },
{ "id": "heretic2", "name": "Heretic II" },
{ "id": "hexen2", "name": "Hexen II" },
{ "id": "hiddendangerous2", "name": "Hidden & Dangerous 2" },
{ "id": "homefront", "name": "Homefront", "isValve": true },
{ "id": "homeworld2", "name": "Homeworld 2" },
{ "id": "hurtworld", "name": "Hurtworld", "isValve": true },
{ "id": "i2cs", "name": "IGI 2: Covert Strike" },
{ "id": "i2s", "name": "IL-2 Sturmovik" },
{ "id": "icarus", "name": "Icarus", "isValve": true },
{ "id": "insurgency", "name": "Insurgency", "isValve": true },
{ "id": "imic", "name": "Insurgency: Modern Infantry Combat", "isValve": true },
{ "id": "insurgencysandstorm", "name": "Insurgency: Sandstorm", "isValve": true },
{ "id": "ironstorm", "name": "Iron Storm" },
{ "id": "jb0n", "name": "James Bond 007: Nightfire" },
{ "id": "jc2m", "name": "Just Cause 2 - Multiplayer" },
{ "id": "jc3m", "name": "Just Cause 3 - Multiplayer", "isValve": true },
{ "id": "kspd", "name": "Kerbal Space Program - DMP" },
{ "id": "killingfloor", "name": "Killing Floor" },
{ "id": "killingfloor2", "name": "Killing Floor 2", "isValve": true },
{ "id": "kloc", "name": "Kingpin: Life of Crime" },
{ "id": "kpctnc", "name": "Kiss: Psycho Circus: The Nightmare Child" },
{ "id": "kreedzclimbing", "name": "Kreedz Climbing", "isValve": true },
{ "id": "l4d", "name": "Left 4 Dead", "isValve": true },
{ "id": "l4d2", "name": "Left 4 Dead 2", "isValve": true },
{ "id": "m2m", "name": "Mafia II - Multiplayer" },
{ "id": "m2o", "name": "Mafia II - Online" },
{ "id": "moh", "name": "Medal of Honor" },
{ "id": "moha", "name": "Medal of Honor: Airborne" },
{ "id": "mohaa", "name": "Medal of Honor: Allied Assault" },
{ "id": "mohaab", "name": "Medal of Honor: Allied Assault Breakthrough" },
{ "id": "mohaas", "name": "Medal of Honor: Allied Assault Spearhead" },
{ "id": "mohpa", "name": "Medal of Honor: Pacific Assault" },
{ "id": "mohw", "name": "Medal of Honor: Warfighter" },
{ "id": "medievalengineers", "name": "Medieval Engineers", "isValve": true },
{ "id": "minecraft", "name": "Minecraft" },
{ "id": "mbe", "name": "Minecraft: Bedrock Edition" },
{ "id": "minetest", "name": "Minetest" },
{ "id": "mnc", "name": "Monday Night Combat", "isValve": true },
{ "id": "mordhau", "name": "Mordhau", "isValve": true },
{ "id": "mumble", "name": "Mumble" },
{ "id": "mgm", "name": "Mumble - GT Murmur" },
{ "id": "mutantfactions", "name": "Mutant Factions" },
{ "id": "moe", "name": "Myth of Empires", "isValve": true },
{ "id": "nascarthunder2004", "name": "NASCAR Thunder 2004" },
{ "id": "naturalselection", "name": "Natural Selection", "isValve": true },
{ "id": "naturalselection2", "name": "Natural Selection 2", "isValve": true },
{ "id": "nfshp2", "name": "Need for Speed: Hot Pursuit 2" },
{ "id": "nab", "name": "Nerf Arena Blast" },
{ "id": "neverwinternights", "name": "Neverwinter Nights" },
{ "id": "neverwinternights2", "name": "Neverwinter Nights 2" },
{ "id": "nexuiz", "name": "Nexuiz" },
{ "id": "nitrofamily", "name": "Nitro Family" },
{ "id": "nmrih", "name": "No More Room in Hell", "isValve": true },
{ "id": "nolf2asihw", "name": "No One Lives Forever 2: A Spy in H.A.R.M.'s Way" },
{ "id": "nla", "name": "Nova-Life: Amboise", "isValve": true },
{ "id": "nucleardawn", "name": "Nuclear Dawn", "isValve": true },
{ "id": "onset", "name": "Onset", "isValve": true },
{ "id": "openarena", "name": "OpenArena" },
{ "id": "openttd", "name": "OpenTTD" },
{ "id": "ofcwc", "name": "Operation Flashpoint: Cold War Crisis" },
{ "id": "ofr", "name": "Operation Flashpoint: Resistance" },
{ "id": "ohd", "name": "Operation: Harsh Doorstop", "isValve": true },
{ "id": "painkiller", "name": "Painkiller" },
{ "id": "palworld", "name": "Palworld" },
{ "id": "pvak2", "name": "Pirates, Vikings, and Knights II", "isValve": true },
{ "id": "pixark", "name": "PixARK", "isValve": true },
{ "id": "postscriptum", "name": "Post Scriptum", "isValve": true },
{ "id": "postal2", "name": "Postal 2" },
{ "id": "prey", "name": "Prey" },
{ "id": "pce", "name": "Primal Carnage: Extinction", "isValve": true },
{ "id": "projectcars", "name": "Project Cars", "isValve": true },
{ "id": "projectcars2", "name": "Project Cars 2", "isValve": true },
{ "id": "prb2", "name": "Project Reality: Battlefield 2" },
{ "id": "projectzomboid", "name": "Project Zomboid", "isValve": true },
{ "id": "quake", "name": "Quake" },
{ "id": "quake2", "name": "Quake 2" },
{ "id": "q3a", "name": "Quake 3: Arena" },
{ "id": "quake4", "name": "Quake 4" },
{ "id": "quakelive", "name": "Quake Live", "isValve": true },
{ "id": "rdkf", "name": "Rag Doll Kung Fu", "isValve": true },
{ "id": "rainbowsix", "name": "Rainbow Six" },
{ "id": "rs2rs", "name": "Rainbow Six 2: Rogue Spear" },
{ "id": "rs3rs", "name": "Rainbow Six 3: Raven Shield" },
{ "id": "rallisportchallenge", "name": "RalliSport Challenge" },
{ "id": "rallymasters", "name": "Rally Masters" },
{ "id": "rdr2r", "name": "Red Dead Redemption 2 - RedM" },
{ "id": "redorchestra", "name": "Red Orchestra" },
{ "id": "redorchestra2", "name": "Red Orchestra 2", "isValve": true },
{ "id": "roo4145", "name": "Red Orchestra: Ostfront 41-45" },
{ "id": "redline", "name": "Redline" },
{ "id": "renegade10", "name": "Renegade X" },
{ "id": "renown", "name": "Renown" },
{ "id": "rtcw", "name": "Return to Castle Wolfenstein" },
{ "id": "ricochet", "name": "Ricochet", "isValve": true },
{ "id": "ron", "name": "Rise of Nations" },
{ "id": "rs2v", "name": "Rising Storm 2: Vietnam", "isValve": true },
{ "id": "risingworld", "name": "Rising World", "isValve": true },
{ "id": "ror2", "name": "Risk of Rain 2", "isValve": true },
{ "id": "rune", "name": "Rune" },
{ "id": "rust", "name": "Rust", "isValve": true },
{ "id": "stalker", "name": "S.T.A.L.K.E.R." },
{ "id": "swat4", "name": "SWAT 4" },
{ "id": "satisfactory", "name": "Satisfactory" },
{ "id": "s2ats", "name": "Savage 2: A Tortured Soul" },
{ "id": "serioussam", "name": "Serious Sam" },
{ "id": "serioussam2", "name": "Serious Sam 2" },
{ "id": "sstse", "name": "Serious Sam: The Second Encounter" },
{ "id": "shatteredhorizon", "name": "Shattered Horizon", "isValve": true },
{ "id": "shogo", "name": "Shogo" },
{ "id": "shootmania", "name": "Shootmania" },
{ "id": "sin", "name": "SiN" },
{ "id": "sinepisodes", "name": "SiN Episodes", "isValve": true },
{ "id": "soldat", "name": "Soldat" },
{ "id": "sof", "name": "Soldier of Fortune" },
{ "id": "sof2", "name": "Soldier of Fortune 2" },
{ "id": "sotf", "name": "Sons Of The Forest", "isValve": true },
{ "id": "soulmask", "name": "Soulmask", "isValve": true },
{ "id": "spaceengineers", "name": "Space Engineers", "isValve": true },
{ "id": "squad", "name": "Squad", "isValve": true },
{ "id": "stbc", "name": "Star Trek: Bridge Commander" },
{ "id": "stvef", "name": "Star Trek: Voyager - Elite Force" },
{ "id": "stvef2", "name": "Star Trek: Voyager - Elite Force 2" },
{ "id": "swjk2jo", "name": "Star Wars Jedi Knight II: Jedi Outcast" },
{ "id": "swjkja", "name": "Star Wars Jedi Knight: Jedi Academy" },
{ "id": "swb", "name": "Star Wars: Battlefront" },
{ "id": "swb2", "name": "Star Wars: Battlefront 2" },
{ "id": "swrc", "name": "Star Wars: Republic Commando" },
{ "id": "starmade", "name": "StarMade" },
{ "id": "starbound", "name": "Starbound", "isValve": true },
{ "id": "starsiege", "name": "Starsiege" },
{ "id": "suicidesurvival", "name": "Suicide Survival", "isValve": true },
{ "id": "stn", "name": "Survive the Nights", "isValve": true },
{ "id": "svencoop", "name": "Sven Coop", "isValve": true },
{ "id": "synergy", "name": "Synergy", "isValve": true },
{ "id": "toxikk", "name": "TOXIKK" },
{ "id": "tacticalops", "name": "Tactical Ops" },
{ "id": "toh", "name": "Take On Helicopters" },
{ "id": "teamfactor", "name": "Team Factor" },
{ "id": "teamfortress2", "name": "Team Fortress 2", "isValve": true },
{ "id": "tfc", "name": "Team Fortress Classic", "isValve": true },
{ "id": "teamspeak2", "name": "Teamspeak 2" },
{ "id": "teamspeak3", "name": "Teamspeak 3" },
{ "id": "terminus", "name": "Terminus" },
{ "id": "terrariatshock", "name": "Terraria - TShock" },
{ "id": "theforest", "name": "The Forest", "isValve": true },
{ "id": "thefront", "name": "The Front", "isValve": true },
{ "id": "thehidden", "name": "The Hidden", "isValve": true },
{ "id": "theisle", "name": "The Isle", "isValve": true },
{ "id": "tie", "name": "The Isle Evrima" },
{ "id": "tonolf", "name": "The Operative: No One Lives Forever" },
{ "id": "theship", "name": "The Ship", "isValve": true },
{ "id": "thespecialists", "name": "The Specialists", "isValve": true },
{ "id": "tcgraw", "name": "Tom Clancy's Ghost Recon Advanced Warfighter" },
{ "id": "tcgraw2", "name": "Tom Clancy's Ghost Recon Advanced Warfighter 2" },
{ "id": "thps3", "name": "Tony Hawk's Pro Skater 3" },
{ "id": "thps4", "name": "Tony Hawk's Pro Skater 4" },
{ "id": "thu2", "name": "Tony Hawk's Underground 2" },
{ "id": "towerunite", "name": "Tower Unite", "isValve": true },
{ "id": "trackmania2", "name": "Trackmania 2" },
{ "id": "trackmaniaforever", "name": "Trackmania Forever" },
{ "id": "tremulous", "name": "Tremulous" },
{ "id": "t1s", "name": "Tribes 1: Starsiege" },
{ "id": "tribesvengeance", "name": "Tribes: Vengeance" },
{ "id": "tron20", "name": "Tron 2.0" },
{ "id": "turok2", "name": "Turok 2" },
{ "id": "universalcombat", "name": "Universal Combat" },
{ "id": "unreal", "name": "Unreal" },
{ "id": "u2tax", "name": "Unreal 2: The Awakening - XMP" },
{ "id": "unrealtournament", "name": "Unreal Tournament" },
{ "id": "unrealtournament2003", "name": "Unreal Tournament 2003" },
{ "id": "unrealtournament2004", "name": "Unreal Tournament 2004" },
{ "id": "unrealtournament3", "name": "Unreal Tournament 3" },
{ "id": "urbanterror", "name": "Urban Terror" },
{ "id": "vrising", "name": "V Rising", "isValve": true },
{ "id": "v8sc", "name": "V8 Supercar Challenge" },
{ "id": "valheim", "name": "Valheim", "isValve": true },
{ "id": "vampireslayer", "name": "Vampire Slayer", "isValve": true },
{ "id": "ventrilo", "name": "Ventrilo" },
{ "id": "vcm", "name": "Vice City Multiplayer" },
{ "id": "vietcong", "name": "Vietcong" },
{ "id": "vietcong2", "name": "Vietcong 2" },
{ "id": "vintagestory", "name": "Vintage Story" },
{ "id": "warfork", "name": "Warfork" },
{ "id": "warsow", "name": "Warsow" },
{ "id": "wot", "name": "Wheel of Time" },
{ "id": "wolfenstein", "name": "Wolfenstein" },
{ "id": "wet", "name": "Wolfenstein: Enemy Territory" },
{ "id": "wop", "name": "World Of Padman" },
{ "id": "wurmunlimited", "name": "Wurm Unlimited", "isValve": true },
{ "id": "xonotic", "name": "Xonotic" },
{ "id": "xpandrally", "name": "Xpand Rally" },
{ "id": "zombiemaster", "name": "Zombie Master", "isValve": true },
{ "id": "zps", "name": "Zombie Panic: Source", "isValve": true },
{ "id": "enshrouded", "name": "enshrouded", "isValve": true },
{ "id": "netpanzer", "name": "netPanzer" },
{ "id": "rfactor", "name": "rFactor" },
{ "id": "rfactor2", "name": "rFactor 2", "isValve": true },
{ "id": "unturned", "name": "unturned", "isValve": true }
]
+327
View File
@@ -0,0 +1,327 @@
export const DiscordJSONTemplate = JSON.stringify(
{
username: "{{site_name}}",
avatar_url: "{{{logo_url}}}",
content:
"## {{alert_name}}\n{{#is_triggered}}🔴 Triggered{{/is_triggered}}{{#is_resolved}}🟢 Resolved{{/is_resolved}}\n{{description}}\nClick [{{action_text}}]({{{action_url}}}) for more.",
embeds: [
{
title: "{{alert_name}}",
description: "{{description}}",
url: "{{{action_url}}}",
color: "{{#is_triggered}}13250616{{/is_triggered}}{{#is_resolved}}5156244{{/is_resolved}}",
fields: [
{
name: "Monitor",
value: "{{metric}}",
inline: false,
},
{
name: "Severity",
value: "{{severity}}",
inline: false,
},
{
name: "Alert ID",
value: "{{id}}",
inline: false,
},
{
name: "Current Value",
value: "{{current_value}}",
inline: true,
},
{
name: "Threshold",
value: "{{threshold}}",
inline: true,
},
],
footer: {
text: "{{source}}",
icon_url: "{{{logo_url}}}",
},
timestamp: "{{timestamp}}",
},
],
},
null,
2,
);
export const EmailHTMLTemplate = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Alert Notification</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
margin: 0;
padding: 0;
background-color: #f4f4f4;
color: #333;
}
.container {
max-width: 600px;
margin: 20px auto;
padding: 20px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.alert-badge {
display: inline-block;
padding: 6px 12px;
border-radius: 16px;
background-color: {{#is_triggered}}{{color_down}}{{/is_triggered}}{{#is_resolved}}{{color_up}}{{/is_resolved}};
color: white;
font-weight: 500;
font-size: 14px;
margin-bottom: 16px;
}
.alert-title {
font-size: 24px;
font-weight: 600;
margin: 0;
color: #2c3e50;
}
.metric-box {
background: #f8f9fa;
border-radius: 6px;
padding: 16px;
margin: 16px 0;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #007bff;
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
margin-top: 20px;
}
.footer {
text-align: center;
padding-top: 20px;
color: #666;
font-size: 14px;
}
</style>
</head>
<body>
<div class="container">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td>
<a href="{{{site_url}}}" style="text-decoration: none;">
<span style="font-size: 24px; font-weight: 600; color: #2c3e50;">{{site_name}}</span>
</a>
</td>
</tr>
</table>
<div class="header">
<div class="alert-badge">{{status}}</div>
<h1 class="alert-title">{{alert_name}}</h1>
</div>
<table width="100%" border="0" cellspacing="0" cellpadding="8">
<tr style="border-bottom: 1px solid #eee;">
<td width="40%" style="color: #666; font-weight: 500;">Alert ID</td>
<td width="60%" style="text-align: right;">{{id}}</td>
</tr>
<tr style="border-bottom: 1px solid #eee;">
<td width="40%" style="color: #666; font-weight: 500;">Status</td>
<td width="60%" style="text-align: right;">{{status}}</td>
</tr>
<tr style="border-bottom: 1px solid #eee;">
<td width="40%" style="color: #666; font-weight: 500;">Time</td>
<td width="60%" style="text-align: right;">{{timestamp}}</td>
</tr>
</table>
<div class="metric-box">
<table width="100%" border="0" cellspacing="0" cellpadding="8">
<tr>
<td width="40%" style="color: #666; font-weight: 500;">Current Value</td>
<td width="60%" style="text-align: right;">{{current_value}}</td>
</tr>
<tr>
<td width="40%" style="color: #666; font-weight: 500;">Threshold Set</td>
<td width="60%" style="text-align: right;">{{threshold}}</td>
</tr>
</table>
</div>
<p style="margin: 20px 0;">{{description}}</p>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center">
<a href="{{{action_url}}}" class="button">{{action_text}}</a>
</td>
</tr>
</table>
<div class="footer">
This is an automated alert notification from {{site_name}} monitoring system.
</div>
</div>
</body>
</html>`;
export const WebhookJSONTemplate = JSON.stringify(
{
id: "{{id}}",
alert_name: "{{alert_name}}",
severity: "{{severity}}",
status: "{{status}}",
source: "{{source}}",
timestamp: "{{timestamp}}",
description: "{{description}}",
details: {
metric: "{{metric}}",
current_value: "{{current_value}}",
threshold: "{{threshold}}",
},
actions: [
{
text: "{{action_text}}",
url: "{{{action_url}}}",
},
],
},
null,
2,
);
export const SlackJSONTemplate = JSON.stringify(
{
blocks: [
{
type: "header",
text: {
type: "plain_text",
text: "{{alert_name}}",
emoji: true,
},
},
{
type: "header",
text: {
type: "plain_text",
text: "{{#is_triggered}}🔴 Triggered{{/is_triggered}}{{#is_resolved}}🟢 Resolved{{/is_resolved}}",
emoji: true,
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: "{{description}}\n*Source:* {{source}}\n*Severity:* {{severity}}\n*Status:* {{status}}",
},
},
{
type: "section",
fields: [
{
type: "mrkdwn",
text: "*Metric:*\n{{metric}}",
},
{
type: "mrkdwn",
text: "*Current Value:*\n{{current_value}}",
},
{
type: "mrkdwn",
text: "*Threshold:*\n{{threshold}}",
},
{
type: "mrkdwn",
text: "*Timestamp:*\n<!date^{{timestamp_unix}}^{date} at {time}|{{timestamp}}>",
},
],
},
{
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "{{action_text}}",
},
url: "{{{action_url}}}",
style: "primary",
},
],
},
],
},
null,
2,
);
export const DefaultAPIEval = `(async function (statusCode, responseTime, responseRaw, modules) {
let statusCodeShort = Math.floor(statusCode/100);
if(statusCode == 429 || (statusCodeShort >=2 && statusCodeShort <= 3)) {
return {
status: 'UP',
latency: responseTime,
}
}
return {
status: 'DOWN',
latency: responseTime,
}
})`;
export const DefaultPingEval = `(async function (arrayOfPings) {
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
return acc + ping.latency;
}, 0);
let alive = arrayOfPings.reduce((acc, ping) => {
return acc && ping.alive;
}, true);
return {
status: alive ? 'UP' : 'DOWN',
latency: latencyTotal / arrayOfPings.length,
}
})`;
export const DefaultTCPEval = `(async function (arrayOfPings) {
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
return acc + ping.latency;
}, 0);
let alive = arrayOfPings.reduce((acc, ping) => {
return acc && ping.status === "open";
}, true);
return {
status: alive ? 'UP' : 'DOWN',
latency: latencyTotal / arrayOfPings.length,
}
})`;
export const DefaultGamedigEval = `(async function (responseTime, responseRaw) {
return {
status: 'UP',
latency: responseTime,
}
})`;
export const GAMEDIG_TIMEOUT = 10 * 1000; // 10 seconds
export const GAMEDIG_SOCKET_TIMEOUT = 2 * 1000; // 2 seconds
export const ErrorSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="60" viewBox="0 0 120 60" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="30" cy="24" r="10"/>
<path d="M26 27h8"/>
<path d="M26 21h2"/>
<path d="M32 21h2"/>
<text x="80" y="29" text-anchor="middle" font-family="system-ui, sans-serif" font-size="14" fill="currentColor" font-weight="300">Not Found</text>
</svg>`;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+21
View File
@@ -0,0 +1,21 @@
export interface AnalyticsEventDetail {
event: string;
data: unknown;
}
export const analyticsEvent = (event: string, data: unknown): void => {
// Do something with the event and data
if (typeof document !== "undefined") {
event = "kener_" + event;
document.dispatchEvent(
new CustomEvent<AnalyticsEventDetail>("analyticsEvent", {
bubbles: true,
detail: {
event,
data,
},
}),
);
}
};
+9
View File
@@ -0,0 +1,9 @@
import { getUnixTime } from "date-fns";
/**
* Get current timestamp in UTC seconds
* @returns Current UTC timestamp in seconds (Unix timestamp)
*/
export function getNowTimestampUTC(): number {
return getUnixTime(new Date());
}
+3
View File
@@ -0,0 +1,3 @@
// Client-only types (safe for +page.ts / components).
export type ThemeMode = "light" | "dark" | "system";
+393
View File
@@ -0,0 +1,393 @@
import type { TimestampStatusCount } from "$lib/server/types/db";
import { PAGE_STATUS_MESSAGES } from "$lib/global-constants";
function ParseLatency(latencyMs: number): string {
if (!!!latencyMs) {
return "";
}
if (latencyMs < 1000) {
return `${Math.round(latencyMs)}ms`;
} else if (latencyMs < 60000) {
return `${(latencyMs / 1000).toFixed(2)}s`;
} else if (latencyMs < 3600000) {
return `${(latencyMs / 60000).toFixed(2)}m`;
} else {
return `${(latencyMs / 3600000).toFixed(2)}h`;
}
}
function siteDataExtractFromDb(data: Record<string, unknown>, obj: Record<string, unknown>): Record<string, unknown> {
let requestedObject: Record<string, unknown> = { ...obj };
for (const key in requestedObject) {
if (Object.prototype.hasOwnProperty.call(requestedObject, key)) {
const element = data[key];
if (data[key]) {
requestedObject[key] = data[key];
}
}
}
//remove any keys that are still null or empty
for (const key in requestedObject) {
if (Object.prototype.hasOwnProperty.call(requestedObject, key)) {
const element = requestedObject[key];
if (element === null || element === "") {
delete requestedObject[key];
}
}
}
return requestedObject;
}
//a function to make an api call to /manage/api/ to store site data
function storeSiteData(data: Record<string, unknown>): Promise<Response> {
return fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ action: "storeSiteData", data }),
});
}
const allRecordTypes = {
A: 1,
NS: 2,
MD: 3,
MF: 4,
CNAME: 5,
SOA: 6,
MB: 7,
MG: 8,
MR: 9,
NULL: 10,
WKS: 11,
PTR: 12,
HINFO: 13,
MINFO: 14,
MX: 15,
TXT: 16,
RP: 17,
AFSDB: 18,
X25: 19,
ISDN: 20,
RT: 21,
NSAP: 22,
NSAP_PTR: 23,
SIG: 24,
KEY: 25,
PX: 26,
GPOS: 27,
AAAA: 28,
LOC: 29,
NXT: 30,
EID: 31,
NIMLOC: 32,
SRV: 33,
ATMA: 34,
NAPTR: 35,
KX: 36,
CERT: 37,
A6: 38,
DNAME: 39,
SINK: 40,
OPT: 41,
APL: 42,
DS: 43,
SSHFP: 44,
IPSECKEY: 45,
RRSIG: 46,
NSEC: 47,
DNSKEY: 48,
DHCID: 49,
NSEC3: 50,
NSEC3PARAM: 51,
TLSA: 52,
SMIMEA: 53,
HIP: 55,
NINFO: 56,
RKEY: 57,
TALINK: 58,
CDS: 59,
CDNSKEY: 60,
OPENPGPKEY: 61,
CSYNC: 62,
SPF: 99,
UINFO: 100,
UID: 101,
GID: 102,
UNSPEC: 103,
NID: 104,
L32: 105,
L64: 106,
LP: 107,
EUI48: 108,
EUI64: 109,
TKEY: 249,
TSIG: 250,
IXFR: 251,
AXFR: 252,
MAILB: 253,
MAILA: 254,
ANY: 255,
};
const ValidateIpAddress = function (input: string): "IP4" | "IP6" | "DOMAIN" | "Invalid" {
// Check if input is a valid IPv4 address with an optional port
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (ipv4Regex.test(input)) {
return "IP4";
}
// Improved IPv6 regex that better handles compressed notation
const ipv6Regex =
/^(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){0,7}:|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){6}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:)|(?:[0-9a-fA-F]{1,4}:){1,7}(?::|:[0-9a-fA-F]{1,4})|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?:(?::[0-9a-fA-F]{1,4}){1,6})|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:))$/;
if (ipv6Regex.test(input)) {
return "IP6";
}
// Check if input is a valid domain name with an optional port
const domainRegex = /^[a-zA-Z0-9]+([\-\.]{1}[a-zA-Z0-9]+)*\.[a-zA-Z]{2,}$/;
if (domainRegex.test(input)) {
return "DOMAIN";
}
// If none of the above conditions match, the input is invalid
return "Invalid";
};
function IsValidHost(domain: string): boolean {
const regex = /^[a-zA-Z0-9]+([\-\.]{1}[a-zA-Z0-9]+)*\.[a-zA-Z]{2,}$/;
return regex.test(domain);
}
function IsValidNameServer(nameServer: string): boolean {
//8.8.8.8 example
const regex = /^([0-9]{1,3}\.){3}[0-9]{1,3}$/;
return regex.test(nameServer);
}
const IsValidURL = function (url: string): boolean {
return /^(http|https):\/\/[^ "]+$/.test(url);
};
function ValidateCronExpression(cronExp: string): { isValid: boolean; message: string } {
// Check if expression is provided and is a string
if (!cronExp || typeof cronExp !== "string") {
return { isValid: false, message: "Cron expression must be a non-empty string" };
}
// Split the expression into its components
const fields = cronExp.trim().split(/\s+/);
// Standard cron should have 5 or 6 fields
// minute hour day-of-month month day-of-week [year]
if (fields.length < 5 || fields.length > 6) {
return {
isValid: false,
message: "Cron expression must have 5 or 6 fields",
};
}
// Define field constraints
const fieldConstraints = [
{ name: "minute", min: 0, max: 59 },
{ name: "hour", min: 0, max: 23 },
{ name: "day", min: 1, max: 31 },
{ name: "month", min: 1, max: 12 },
{ name: "weekday", min: 0, max: 6 },
{ name: "year", min: 1970, max: 2099 }, // Optional field
];
// Valid characters in cron expressions
const validChars = /^[\d/*,\-]+$/;
// Validate each field
for (let i = 0; i < fields.length; i++) {
const field = fields[i];
const constraint = fieldConstraints[i];
// Check for valid characters
if (!validChars.test(field)) {
return {
isValid: false,
message: `Invalid characters in ${constraint.name} field`,
};
}
// Handle special characters
if (field === "*") {
continue; // Asterisk is valid for all fields
}
// Handle lists (comma-separated values)
if (field.includes(",")) {
const values = field.split(",");
for (const value of values) {
if (!isValidRange(value, constraint.min, constraint.max)) {
return {
isValid: false,
message: `Invalid value in ${constraint.name} field: ${value}`,
};
}
}
continue;
}
// Handle ranges (with hyphens)
if (field.includes("-")) {
const [start, end] = field.split("-").map(Number);
if (start == null || end == null || start < constraint.min || end > constraint.max || start > end) {
return {
isValid: false,
message: `Invalid range in ${constraint.name} field: ${field}`,
};
}
continue;
}
// Handle steps (with forward slash)
if (field.includes("/")) {
const [range, step] = field.split("/");
if (range !== "*" && !isValidRange(range, constraint.min, constraint.max)) {
return {
isValid: false,
message: `Invalid range in ${constraint.name} field: ${range}`,
};
}
if (!isValidRange(step, 1, constraint.max)) {
return {
isValid: false,
message: `Invalid step value in ${constraint.name} field: ${step}`,
};
}
continue;
}
// Handle plain numbers
if (!isValidRange(field, constraint.min, constraint.max)) {
return {
isValid: false,
message: `Invalid value in ${constraint.name} field: ${field}`,
};
}
}
return { isValid: true, message: "Valid cron expression" };
}
function isValidRange(value: string | number, min: number, max: number): boolean {
const num = Number(value);
return !isNaN(num) && num >= min && num <= max;
}
function IsValidPort(port: number | string): boolean {
return isValidRange(port, 1, 65535);
}
export interface MonitorItem {
id: number;
tag: string;
name: string;
description: string | null;
monitor_type: string | null;
image: string | null;
category_name: string | null;
day_degraded_minimum_count: number | null;
day_down_minimum_count: number | null;
include_degraded_in_downtime: string;
[key: string]: unknown;
}
function SortMonitor(monitorSort: number[] | undefined, resp: MonitorItem[]): MonitorItem[] {
let monitors: MonitorItem[] = [];
if (!!monitorSort && monitorSort.length > 0) {
monitors = monitorSort
.map((id: number) => resp.find((m: MonitorItem) => m.id == id))
.filter((m): m is MonitorItem => !!m);
//append any new monitors
monitors = [...monitors, ...resp.filter((m: MonitorItem) => !monitorSort.includes(m.id))];
} else {
monitors = resp;
}
return monitors;
}
//js function to generate 32 character random string
function RandomString(length: number): string {
const chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
let result = "";
for (let i = length; i > 0; --i) result += chars[Math.floor(Math.random() * chars.length)];
return result;
}
interface GameItem {
id: string;
[key: string]: unknown;
}
/**
* Retreive game's data from its id.
* @param {string} id Id
* @return Returns game's data if found, undefined instead.
*/
function GetGameFromId(list: GameItem[], id: string): GameItem | undefined {
return list.find((game: GameItem) => game.id === id);
}
function GetStatusSummary(item: TimestampStatusCount): string {
const total = item.countOfUp + item.countOfDown + item.countOfDegraded + item.countOfMaintenance;
if (total === 0) return PAGE_STATUS_MESSAGES.NO_DATA;
const maintenancePercent = (item.countOfMaintenance / total) * 100;
const downPercent = (item.countOfDown / total) * 100;
const degradedPercent = (item.countOfDegraded / total) * 100;
if (maintenancePercent > 0) {
return PAGE_STATUS_MESSAGES.UNDER_MAINTENANCE;
} else if (downPercent >= 75) {
return PAGE_STATUS_MESSAGES.MAJOR_OUTAGE;
} else if (downPercent >= 50) {
return PAGE_STATUS_MESSAGES.PARTIAL_OUTAGE;
} else if (item.countOfDown > 0) {
return PAGE_STATUS_MESSAGES.PARTIAL_OUTAGE;
} else if (degradedPercent >= 75) {
return PAGE_STATUS_MESSAGES.DEGRADED_PERFORMANCE;
} else if (degradedPercent >= 50) {
return PAGE_STATUS_MESSAGES.PARTIAL_DEGRADED;
} else if (item.countOfDegraded > 0) {
return PAGE_STATUS_MESSAGES.PARTIAL_DEGRADED;
} else if (item.countOfUp === total) {
return PAGE_STATUS_MESSAGES.ALL_OPERATIONAL;
}
return PAGE_STATUS_MESSAGES.NO_DATA;
}
function GetStatusColor(item: TimestampStatusCount): string {
const total = item.countOfUp + item.countOfDown + item.countOfDegraded + item.countOfMaintenance;
if (total === 0) return "text-muted-foreground";
const maintenancePercent = (item.countOfMaintenance / total) * 100;
const downPercent = (item.countOfDown / total) * 100;
if (maintenancePercent > 0) return "text-maintenance";
if (downPercent > 0) return "text-down";
if (item.countOfDegraded > 0) return "text-degraded";
return "text-up";
}
function GetStatusBgColor(item: TimestampStatusCount): string {
let textColor = GetStatusColor(item);
return textColor.replace("text-", "bg-");
}
export {
siteDataExtractFromDb,
storeSiteData,
allRecordTypes,
ValidateIpAddress,
IsValidHost,
IsValidNameServer,
IsValidURL,
IsValidPort,
ValidateCronExpression,
SortMonitor,
RandomString,
GetGameFromId,
GetStatusSummary,
GetStatusColor,
GetStatusBgColor,
ParseLatency,
};
+29
View File
@@ -0,0 +1,29 @@
import { GetAllSiteData } from "$lib/server/controllers/controller.js";
export interface StatusColors {
UP: string;
DEGRADED: string;
DOWN: string;
MAINTENANCE: string;
NO_DATA: string;
}
interface SiteColors {
UP?: string;
DEGRADED?: string;
DOWN?: string;
MAINTENANCE?: string;
}
async function StatusColor(_status?: string): Promise<StatusColors> {
let site = (await GetAllSiteData()) as { colors?: SiteColors };
return {
UP: site.colors?.UP || "#00dfa2",
DEGRADED: site.colors?.DEGRADED || "#e6ca61",
DOWN: site.colors?.DOWN || "#ca3038",
MAINTENANCE: site.colors?.MAINTENANCE || "#6679cc",
NO_DATA: "#f1f5f8",
};
}
export default StatusColor;
@@ -0,0 +1,198 @@
<script lang="ts">
import { format } from "date-fns";
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";
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));
// Format duration from seconds to human-readable string
function formatDuration(startTs: number, endTs: number): string {
const seconds = endTs - startTs;
if (seconds <= 0) return "0m";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h`;
return `${minutes}m`;
}
// 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">Maintenances</Badge>
</div>
<div class="grid grid-cols-3 gap-0">
<!-- Ongoing Maintenances -->
<div class="flex flex-col border-t border-r">
<div class="text-muted-foreground flex items-center justify-between gap-2 border-b p-4 text-sm font-medium">
<div class="flex items-center gap-2">
<div class="bg-maintenance h-2 w-2 rounded-full"></div>
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">No ongoing maintenances</p>
{:else}
{#each uniqueOngoing as maintenance (maintenance.id)}
<a
href="/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" />
{format(new Date(maintenance.start_date_time * 1000), "MMM d, HH:mm")}
</span>
<span class="flex items-center gap-1">
<Timer class="h-3 w-3" />
{formatDuration(maintenance.start_date_time, maintenance.end_date_time)}
</span>
</div>
</a>
{/each}
{/if}
</div>
</div>
<!-- Upcoming Maintenances -->
<div class="flex flex-col border-t border-r">
<div class="text-muted-foreground flex items-center justify-between gap-2 border-b p-4 text-sm font-medium">
<div class="flex items-center gap-2">
<div class="bg-primary h-2 w-2 rounded-full"></div>
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">No upcoming maintenances</p>
{:else}
{#each uniqueUpcoming as maintenance (maintenance.id)}
<a
href="/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" />
{format(new Date(maintenance.start_date_time * 1000), "MMM d, HH:mm")}
</span>
<span class="flex items-center gap-1">
<Timer class="h-3 w-3" />
{formatDuration(maintenance.start_date_time, maintenance.end_date_time)}
</span>
</div>
</a>
{/each}
{/if}
</div>
</div>
<!-- Past Maintenances -->
<div class="flex flex-col border-t">
<div class="text-muted-foreground flex items-center justify-between gap-2 border-b p-4 text-sm font-medium">
<div class="flex items-center gap-2">
<div class="bg-muted-foreground h-2 w-2 rounded-full"></div>
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">No past maintenances</p>
{:else}
{#each uniquePast as maintenance (maintenance.id)}
<a
href="/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" />
{format(new Date(maintenance.start_date_time * 1000), "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}
+104
View File
@@ -0,0 +1,104 @@
<script lang="ts">
import * as Dialog from "$lib/components/ui/dialog/index.js";
import CopyButton from "$lib/components/CopyButton.svelte";
import GC from "$lib/global-constants.js";
import { resolve } from "$app/paths";
import TrendingUp from "@lucide/svelte/icons/trending-up";
import Percent from "@lucide/svelte/icons/percent";
import Copy from "@lucide/svelte/icons/copy";
interface Props {
open: boolean;
monitorTag: string;
protocol: string;
domain: string;
}
let { open = $bindable(false), monitorTag, protocol, domain }: Props = $props();
// Badge URLs
const badgeStatusUrl = $derived(
protocol && domain ? `${protocol}//${domain}${resolve("/")}badge/${monitorTag}/status` : ""
);
const badgeUptimeUrl = $derived(
protocol && domain ? `${protocol}//${domain}${resolve("/")}badge/${monitorTag}/uptime` : ""
);
const badgeDotUrl = $derived(
protocol && domain ? `${protocol}//${domain}${resolve("/")}badge/${monitorTag}/dot` : ""
);
const badgeDotPingUrl = $derived(
protocol && domain ? `${protocol}//${domain}${resolve("/")}badge/${monitorTag}/dot?animate=ping` : ""
);
</script>
<Dialog.Root bind:open>
<Dialog.Overlay class="backdrop-blur-[2px]" />
<Dialog.Content class="max-w-md rounded-3xl">
<Dialog.Header>
<Dialog.Title>Badges</Dialog.Title>
<Dialog.Description>Get badges for this monitor</Dialog.Description>
</Dialog.Header>
<div class="flex flex-col gap-6">
<!-- Badges Section -->
<div>
<h3 class="mb-2 text-sm font-semibold">SVG Badges</h3>
<div class="flex flex-col gap-2">
{#if badgeStatusUrl}
<div class="flex items-center justify-between gap-2 rounded-3xl border px-2 py-1">
<div class="flex items-center gap-2">
<TrendingUp class="h-3 w-3" />
<span class="text-xs font-medium">Status Badge</span>
</div>
<img src={badgeStatusUrl} alt="Status Badge" class="h-5" />
<CopyButton variant="ghost" size="icon-sm" text={badgeStatusUrl} class="rounded-btn">
<Copy />
</CopyButton>
</div>
{/if}
{#if badgeUptimeUrl}
<div class="flex items-center justify-between gap-2 rounded-3xl border px-2 py-1">
<div class="flex items-center gap-2">
<Percent class="h-3 w-3" />
<span class="text-xs font-medium">Uptime Badge</span>
</div>
<img src={badgeUptimeUrl} alt="Uptime Badge" class="h-5" />
<CopyButton variant="ghost" size="icon-sm" text={badgeUptimeUrl} class="rounded-btn hover:bg-transparent">
<Copy />
</CopyButton>
</div>
{/if}
</div>
</div>
<!-- Live Status Section -->
<div>
<h3 class="mb-2 text-sm font-semibold">Live Status</h3>
<div class="flex flex-row gap-2">
{#if badgeDotUrl}
<div class="flex items-center gap-2 rounded-3xl border px-2 py-1">
<img src={badgeDotUrl} alt="Status Dot" class="h-4" />
<span class="text-xs font-medium">Standard</span>
<CopyButton variant="ghost" size="icon-sm" text={badgeDotUrl} class="rounded-btn">
<Copy />
</CopyButton>
</div>
{/if}
{#if badgeDotPingUrl}
<div class="flex items-center gap-2 rounded-3xl border px-2 py-1">
<img src={badgeDotPingUrl} alt="Status Dot Ping" class="h-4" />
<span class="text-xs font-medium">Pinging</span>
<CopyButton variant="ghost" size="icon-sm" text={badgeDotPingUrl} class="rounded-btn">
<Copy />
</CopyButton>
</div>
{/if}
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
+54
View File
@@ -0,0 +1,54 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button/index.js";
import { UseClipboard } from "$lib/hooks/use-clipboard.svelte.js";
import Check from "@lucide/svelte/icons/check";
import type { Snippet } from "svelte";
interface Props {
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
size?: "default" | "sm" | "lg" | "icon" | "icon-sm";
text?: string;
onclick?: () => void;
children?: Snippet;
class?: string;
}
let { variant = "outline", size = "icon-sm", text, onclick, children, class: className }: Props = $props();
const clipboard = new UseClipboard({ delay: 1000 });
async function handleClick() {
if (text) {
await clipboard.copy(text);
}
if (onclick) {
onclick();
}
}
</script>
<Button {variant} {size} class="{className} relative" onclick={handleClick}>
<span
class="absolute flex items-center transition-all duration-200 ease-out {clipboard.copied
? 'scale-100 opacity-100'
: 'pointer-events-none scale-75 opacity-0'}"
>
<Check class="h-4 w-4 stroke-green-500" />
</span>
<span
class="flex items-center transition-all duration-200 ease-out {clipboard.copied
? 'pointer-events-none scale-75 opacity-0'
: 'scale-100 opacity-100'}"
>
{#if children}
{@render children()}
{/if}
</span>
<span
class="bg-popover text-popover-foreground absolute bottom-full left-1/2 mb-2 origin-bottom -translate-x-1/2 rounded-md border px-2 py-1 text-xs shadow-md transition-all duration-200 ease-out {clipboard.copied
? 'scale-100 opacity-100'
: 'pointer-events-none scale-75 opacity-0'}"
>
Copied
</span>
</Button>
+198
View File
@@ -0,0 +1,198 @@
<script lang="ts">
import * as Dialog from "$lib/components/ui/dialog/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import CopyButton from "$lib/components/CopyButton.svelte";
import { resolve } from "$app/paths";
import Code from "@lucide/svelte/icons/code";
import ExternalLink from "@lucide/svelte/icons/external-link";
import Copy from "@lucide/svelte/icons/copy";
interface Props {
open: boolean;
monitorTag: string;
protocol: string;
domain: string;
}
const base = resolve("/");
let { open = $bindable(false), monitorTag, protocol, domain }: Props = $props();
let monitorTheme = $state<"light" | "dark">("light");
let monitorEmbedType = $state<"script" | "iframe">("iframe");
let latencyTheme = $state<"light" | "dark">("light");
let latencyEmbedType = $state<"script" | "iframe">("iframe");
// Monitor Embed URL
const monitorEmbedUrl = $derived(() => {
if (!protocol || !domain) return "";
return `${protocol}//${domain}${base}embed/monitor-${monitorTag}`;
});
// Monitor Preview URL
const monitorPreviewUrl = $derived(() => {
const url = monitorEmbedUrl();
if (!url) return "";
return `${url}`;
});
// Monitor Embed code
const monitorEmbedCode = $derived(() => {
if (!protocol || !domain) return "";
const url = monitorEmbedUrl();
const fullUrl = `${url}?theme=${monitorTheme}`;
if (monitorEmbedType === "iframe") {
return `<iframe src="${fullUrl}" width="100%" height="200" allowfullscreen="allowfullscreen" allowpaymentrequest frameborder="0"></iframe>`;
}
return `<script src="${url}/js?theme=${monitorTheme}&monitor=${url}"><` + "/script>";
});
// Latency Embed URL
const latencyEmbedUrl = $derived(() => {
if (!protocol || !domain) return "";
return `${protocol}//${domain}${base}embed/latency-${monitorTag}`;
});
// Latency Preview URL
const latencyPreviewUrl = $derived(() => {
const url = latencyEmbedUrl();
if (!url) return "";
return `${url}`;
});
// Latency Embed code
const latencyEmbedCode = $derived(() => {
if (!protocol || !domain) return "";
const url = latencyEmbedUrl();
const fullUrl = `${url}?theme=${latencyTheme}`;
if (latencyEmbedType === "iframe") {
return `<iframe src="${fullUrl}" width="100%" height="200" allowfullscreen="allowfullscreen" allowpaymentrequest frameborder="0"></iframe>`;
}
return `<script src="${url}/js?theme=${latencyTheme}&monitor=${url}"><` + "/script>";
});
</script>
<Dialog.Root bind:open>
<Dialog.Overlay class="backdrop-blur-[2px]" />
<Dialog.Content class="min-w-157.5 rounded-3xl">
<Dialog.Header>
<Dialog.Title>Embed Monitor</Dialog.Title>
<Dialog.Description>Embed this monitor in your website or app</Dialog.Description>
</Dialog.Header>
<div class="flex flex-col gap-4">
<!-- Status Embed -->
<div>
<Label class="mb-2 block text-sm font-semibold">Status Embed</Label>
<div class="flex flex-col gap-4 rounded-3xl border p-4">
<iframe title="status embed preview" src={monitorPreviewUrl()} width="100%" height="70" frameborder="0"
></iframe>
<div class="flex items-center justify-between gap-4 p-2">
<div>
<Label class="mb-2 block text-xs">Theme</Label>
<div class="flex gap-2">
<Button
variant={monitorTheme === "light" ? "default" : "outline"}
size="sm"
onclick={() => (monitorTheme = "light")}
>
Light
</Button>
<Button
variant={monitorTheme === "dark" ? "default" : "outline"}
size="sm"
onclick={() => (monitorTheme = "dark")}
>
Dark
</Button>
</div>
</div>
<div>
<Label class="mb-2 block text-xs">Format</Label>
<div class="flex gap-2">
<Button
variant={monitorEmbedType === "iframe" ? "default" : "outline"}
size="sm"
onclick={() => (monitorEmbedType = "iframe")}
>
iFrame
</Button>
<Button
variant={monitorEmbedType === "script" ? "default" : "outline"}
size="sm"
onclick={() => (monitorEmbedType = "script")}
>
Script
</Button>
</div>
</div>
<div class="flex items-center gap-2">
<CopyButton variant="ghost" size="icon-sm" text={monitorEmbedCode()} class="rounded-btn">
<Copy class="h-3 w-3" />
</CopyButton>
</div>
</div>
</div>
</div>
<!-- Latency Embed -->
<div>
<Label class="mb-2 block text-sm font-semibold">Latency Embed</Label>
<div class="flex flex-col gap-4 rounded-3xl border p-4">
<iframe title="latency embed preview" src={latencyPreviewUrl()} width="100%" height="200" frameborder="0"
></iframe>
<div class="flex items-end justify-between gap-4 p-2">
<div>
<Label class="mb-2 block text-xs">Theme</Label>
<div class="flex gap-2">
<Button
variant={latencyTheme === "light" ? "default" : "outline"}
size="sm"
onclick={() => (latencyTheme = "light")}
>
Light
</Button>
<Button
variant={latencyTheme === "dark" ? "default" : "outline"}
size="sm"
onclick={() => (latencyTheme = "dark")}
>
Dark
</Button>
</div>
</div>
<div class="">
<Label class="mb-2 block text-xs">Format</Label>
<div class="flex gap-2">
<Button
variant={latencyEmbedType === "iframe" ? "default" : "outline"}
size="sm"
onclick={() => (latencyEmbedType = "iframe")}
>
iFrame
</Button>
<Button
variant={latencyEmbedType === "script" ? "default" : "outline"}
size="sm"
onclick={() => (latencyEmbedType = "script")}
>
Script
</Button>
</div>
</div>
<div class="flex items-center gap-2">
<CopyButton variant="ghost" size="icon-sm" text={latencyEmbedCode()} class="rounded-btn">
<Copy class="h-3 w-3" />
</CopyButton>
</div>
</div>
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import Check from "@lucide/svelte/icons/check";
interface Props {
ongoingMaintenancesCount: number;
ongoingIncidentsCount: number;
upcomingMaintenancesCount: number;
statusClass: string;
statusText: string;
}
let { ongoingMaintenancesCount, ongoingIncidentsCount, upcomingMaintenancesCount, statusClass, statusText }: Props =
$props();
// Compute change info (direction, color, and formatted value)
</script>
<div class="flex gap-3">
<div class="flex h-20 flex-row justify-start gap-y-3 rounded-3xl border p-4">
<div class="flex flex-row items-center gap-4">
<div class="relative flex justify-between">
<span class="relative flex size-4">
<span class="{statusClass} absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
<span class="{statusClass} relative inline-flex size-4 rounded-full"></span>
</span>
</div>
<div class="flex flex-col items-start gap-2">
<p class=" text-2xl">{statusText}</p>
</div>
</div>
</div>
<div class="flex h-20 flex-1 flex-col justify-around gap-y-4 rounded-3xl border p-4">
<div class=" flex gap-x-3">
<div class="flex flex-1 flex-row items-center gap-2">
{#if ongoingIncidentsCount === 0}
<Check class="text-up" />
{:else}
<p class="text-3xl">
{ongoingIncidentsCount}
</p>
{/if}
<p class="text-xs leading-4 font-medium">
<span class="block">Ongoing</span>
<span class="block">Incidents</span>
</p>
</div>
<div class="flex flex-1 flex-row items-center gap-2">
{#if ongoingMaintenancesCount === 0}
<Check class="text-up" />
{:else}
<p class="text-3xl">
{ongoingMaintenancesCount}
</p>
{/if}
<p class="text-xs leading-4 font-medium">
<span class="block">Ongoing</span>
<span class="block">Maintenances</span>
</p>
</div>
<div class="flex flex-1 flex-row items-center gap-2">
{#if upcomingMaintenancesCount === 0}
<Check class="text-up" />
{:else}
<p class="text-3xl">
{upcomingMaintenancesCount}
</p>
{/if}
<p class="text-xs leading-4 font-medium">
<span class="block">Upcoming</span>
<span class="block">Maintenances</span>
</p>
</div>
</div>
</div>
</div>
+156
View File
@@ -0,0 +1,156 @@
<script lang="ts">
import { format, formatDistanceStrict } from "date-fns";
import * as Item from "$lib/components/ui/item/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import ArrowRight from "@lucide/svelte/icons/arrow-right";
import * as Popover from "$lib/components/ui/popover/index.js";
import * as Avatar from "$lib/components/ui/avatar/index.js";
import STATUS_ICON from "$lib/icons";
interface IncidentMonitorImpact {
monitor_tag: string;
monitor_impact: string;
monitor_name: string;
monitor_image: string | null;
}
interface Incident {
id: number;
title: string;
monitors: IncidentMonitorImpact[];
start_date_time: number;
end_date_time?: number | null;
}
interface Props {
incident: Incident;
class?: string;
hideMonitors?: boolean;
}
let { incident, class: className = "", hideMonitors = false }: Props = $props();
const STATUS_STROKE = {
UP: "stroke-up",
DOWN: "stroke-down",
DEGRADED: "stroke-degraded",
MAINTENANCE: "stroke-maintenance",
NO_DATA: "stroke-muted-foreground"
} as const;
// Get the highest severity impact from monitors array
function getHighestImpact(monitors: IncidentMonitorImpact[]): keyof typeof STATUS_ICON {
const priority: (keyof typeof STATUS_ICON)[] = ["DOWN", "DEGRADED", "MAINTENANCE"];
for (const impact of priority) {
if (monitors.some((m) => m.monitor_impact === impact)) {
return impact;
}
}
return (monitors[0]?.monitor_impact as keyof typeof STATUS_ICON) || "NO_DATA";
}
// Get initials from monitor name for avatar fallback
function getInitials(name: string): string {
return name
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
const highestImpact = $derived(getHighestImpact(incident.monitors));
const Icon = $derived(STATUS_ICON[highestImpact]);
const strokeClass = $derived(STATUS_STROKE[highestImpact as keyof typeof STATUS_STROKE] || "stroke-down");
// Calculate duration between start and end (or now if ongoing)
const duration = $derived(() => {
const startDate = new Date(incident.start_date_time * 1000);
const endDate = incident.end_date_time ? new Date(incident.end_date_time * 1000) : new Date();
return formatDistanceStrict(startDate, endDate);
});
</script>
<Item.Root class="p-0 {className}">
<Item.Media>
<Icon class="size-6 {strokeClass}" />
</Item.Media>
<Item.Content>
<div class="flex items-center gap-2">
<Item.Title>{incident.title}</Item.Title>
</div>
{#if incident.monitors && incident.monitors.length > 0 && !hideMonitors}
<div class="*:data-[slot=avatar]:ring-background my-1 flex -space-x-2 *:data-[slot=avatar]:ring-2">
{#each incident.monitors as monitor}
<Popover.Root>
<Popover.Trigger>
<Avatar.Root
class="bg-background size-8 cursor-pointer border-2 border-{monitor.monitor_impact.toLowerCase()} transition-transform duration-100 ease-in-out hover:scale-[1.1] hover:border"
>
{#if monitor.monitor_image}
<Avatar.Image src={monitor.monitor_image} alt={monitor.monitor_name} class="object-cover" />
{/if}
<Avatar.Fallback class="text-xs">{getInitials(monitor.monitor_name)}</Avatar.Fallback>
</Avatar.Root>
</Popover.Trigger>
<Popover.Content class="w-64">
<div class="flex flex-col gap-3">
<div class="flex items-center gap-3">
<Avatar.Root>
{#if monitor.monitor_image}
<Avatar.Image src={monitor.monitor_image} alt={monitor.monitor_name} />
{/if}
<Avatar.Fallback>{getInitials(monitor.monitor_name)}</Avatar.Fallback>
</Avatar.Root>
<div class="flex flex-col">
<span class="font-medium">{monitor.monitor_name}</span>
<span class="text-muted-foreground text-xs">{monitor.monitor_tag}</span>
</div>
</div>
<div class="flex items-center justify-between">
<Badge variant="outline" class="text-{monitor.monitor_impact.toLowerCase()}">
{monitor.monitor_impact}
</Badge>
<Button variant="outline" size="sm" href="/monitors/{monitor.monitor_tag}">
View Monitor
<ArrowRight class="ml-1 size-3" />
</Button>
</div>
</div>
</Popover.Content>
</Popover.Root>
{/each}
</div>
{/if}
<Item.Description>
<div class="mt-2 flex items-center justify-between text-xs font-medium">
<div class="rounded-full border px-3 py-2">
{format(new Date(incident.start_date_time * 1000), "PPp")}
</div>
<div class="relative flex-1 text-center">
<div class="absolute top-1/2 right-0 left-0 border-t"></div>
<span class="bg-background relative z-10 px-2 py-1">{duration()}</span>
</div>
{#if incident.end_date_time}
<div class="rounded-full border px-3 py-2">
{format(new Date(incident.end_date_time * 1000), "PPp")}
</div>
{:else}
<div class=" rounded-full border px-3 py-2">Ongoing</div>
{/if}
</div>
</Item.Description>
</Item.Content>
<Item.Actions>
<Button
variant="outline"
class="cursor-pointer rounded-full shadow-none"
href="/incidents/{incident.id}"
size="icon"
>
<ArrowRight />
</Button>
</Item.Actions>
</Item.Root>
@@ -0,0 +1,42 @@
<script lang="ts">
import { Badge } from "$lib/components/ui/badge/index.js";
import IncidentItem from "$lib/components/IncidentItem.svelte";
interface IncidentMonitorImpact {
monitor_tag: string;
monitor_impact: string;
monitor_name: string;
monitor_image: string | null;
}
interface Incident {
id: number;
title: string;
monitors: IncidentMonitorImpact[];
start_date_time: number;
end_date_time?: number | null;
}
interface Props {
incidents: Incident[];
title: string;
class?: string;
}
let { incidents, title, class: className = "" }: Props = $props();
</script>
{#if incidents && incidents.length > 0}
<div class="bg-background rounded-3xl border p-0 {className}">
<div class="flex items-center justify-between p-4">
<Badge variant="secondary" class="gap-1">{title}</Badge>
</div>
<div class="">
{#each incidents as incident (incident.id)}
<div class="border-b p-4 last:border-b-0">
<IncidentItem {incident} />
</div>
{/each}
</div>
</div>
{/if}
+269
View File
@@ -0,0 +1,269 @@
<script>
import { formatDistanceToNow, formatDistance } from "date-fns";
import Settings from "lucide-svelte/icons/settings";
import ArrowRight from "lucide-svelte/icons/arrow-right";
import Copy from "lucide-svelte/icons/copy";
import Check from "lucide-svelte/icons/check";
import { analyticsEvent } from "$lib/boringOne";
import * as Accordion from "$lib/components/ui/accordion";
import { l, f, fd, fdn } from "$lib/i18n/client";
import { base } from "$app/paths";
import { Button } from "$lib/components/ui/button";
import { Tooltip } from "bits-ui";
import GMI from "$lib/components/gmi.svelte";
import { page } from "$app/state";
import { marked } from "marked";
import { onMount } from "svelte";
export let incident;
export let index;
export let allowCollapse = true;
let startTime = new Date(incident.start_date_time * 1000);
let endTime = new Date();
let nowTime = new Date();
if (incident.end_date_time) {
endTime = new Date(incident.end_date_time * 1000);
}
let incidentType = incident.incident_type;
const lastedFor = fd(startTime, endTime, $page.data.selectedLang);
const remainingTime = fd(nowTime, endTime, $page.data.selectedLang);
const startedAt = fdn(startTime, $page.data.selectedLang);
let incidentTimeStatus = "";
if (nowTime < startTime) {
incidentTimeStatus = "YET_TO_START";
} else if (nowTime >= startTime && nowTime <= endTime) {
incidentTimeStatus = "ONGOING";
} else if (nowTime > endTime) {
incidentTimeStatus = "COMPLETED";
}
let accordionValue = "incident-0";
if ($page.data.site.incidentGroupView == "COLLAPSED" && allowCollapse) {
accordionValue = "incident-collapse";
} else if ($page.data.site.incidentGroupView == "EXPANDED") {
accordionValue = index;
}
let incidentDateSummary = "";
let maintenanceBadge = "";
let maintenanceBadgeColor = "";
if (incidentTimeStatus == "YET_TO_START") {
incidentDateSummary = l($page.data.lang, "Starts %startedAt", { startedAt });
if (incidentType === "MAINTENANCE") {
incidentDateSummary = l($page.data.lang, "Starts %startedAt, will last for %lastedFor", {
startedAt,
lastedFor
});
maintenanceBadge = "Upcoming Maintenance";
maintenanceBadgeColor = "text-upcoming-maintenance";
}
} else if (incidentTimeStatus == "ONGOING") {
incidentDateSummary = l($page.data.lang, "Started %startedAt, still ongoing", {
startedAt
});
if (incidentType === "MAINTENANCE") {
incidentDateSummary = l($page.data.lang, "Started %startedAt, will last for %lastedFor more", {
startedAt,
lastedFor: remainingTime
});
maintenanceBadge = "Maintenance in Progress";
maintenanceBadgeColor = "text-maintenance-in-progress";
}
} else if (incidentTimeStatus == "COMPLETED") {
incidentDateSummary = l($page.data.lang, "Started %startedAt, lasted for %lastedFor", {
startedAt,
lastedFor
});
maintenanceBadge = "Maintenance Completed";
maintenanceBadgeColor = "text-maintenance-completed";
}
let pathMonitorLink = "";
onMount(async () => {
let protocol = window.location.protocol;
let domain = window.location.host;
pathMonitorLink = `${protocol}//${domain}${base}/view/events/${incident.incident_type}-${incident.id}`;
});
</script>
<div class="newincident relative grid w-full grid-cols-12 gap-2 px-0 py-0 last:border-b-0">
<div class="col-span-12">
<Accordion.Root
bind:value={index}
class="accor {allowCollapse === false ? 'hide-chevron' : ''}"
disabled={!allowCollapse}
>
<Accordion.Item value={accordionValue}>
<Accordion.Trigger
class="hover:bg-muted rounded-md px-4 hover:no-underline"
on:click={() => analyticsEvent("incident_open", { incident_title: incident.title })}
>
<div class="w-full text-left hover:no-underline">
<div class="flex justify-start gap-x-2">
<p class="flex gap-x-2 text-xs font-semibold">
{#if incidentType == "INCIDENT"}
<span class="badge-{incident.state}">
{l($page.data.lang, incident.state)}
</span>
{:else if incidentType == "MAINTENANCE"}
<span class="{maintenanceBadgeColor} ">
{l($page.data.lang, maintenanceBadge)}
</span>
{/if}
</p>
<div class="flex justify-end gap-x-2">
{#if $page.data.isLoggedIn}
<Button
href="{base}/manage/app/events#{incident.id}"
class="rotate-once text-muted-foreground hover:text-primary h-5 p-0"
variant="link"
>
<Settings class="h-4 w-4 " />
</Button>
{/if}
{#if !!pathMonitorLink}
<Button
size="icon"
variant="link"
class="copybtn text-muted-foreground hover:text-primary relative h-5 p-0"
on:click={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(pathMonitorLink);
analyticsEvent("incident_copy_link", { incident_title: incident.title });
}}
>
<Check class="check-btn absolute top-0 left-0 h-4 w-4 text-green-500" />
<Copy class="copy-btn absolute top-0 left-0 h-4 w-4 " />
</Button>
{/if}
</div>
</div>
<p class="font-medium">
{incident.title}
</p>
{#if !!incidentDateSummary}
<Tooltip.Root openDelay={100} side="bottom" align="end">
<Tooltip.Trigger
class=" text-muted-foreground text-xs font-medium tracking-normal text-ellipsis whitespace-nowrap"
>
{incidentDateSummary}
</Tooltip.Trigger>
<Tooltip.Content class=" z-20 mt-11" side="bottom" align="end">
<div
class="bg-primary text-primary-foreground shadow-popover items-center justify-center rounded border px-1.5 py-1 text-xs font-medium"
>
{f(
new Date(incident.start_date_time * 1000),
"MMMM do yyyy, h:mm:ss a",
$page.data.selectedLang,
$page.data.localTz
)}
{#if incident.end_date_time}
<ArrowRight class="mx-1 -mt-0.5 inline h-3 w-3" />
{f(
new Date(incident.end_date_time * 1000),
"MMMM do yyyy, h:mm:ss a",
$page.data.selectedLang,
$page.data.localTz
)}
{:else}
<ArrowRight class="mx-1 -mt-0.5 inline h-3 w-3" />
<span class="dots-animation inline-block w-6 text-left"></span>
{/if}
</div>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
</Accordion.Trigger>
<Accordion.Content>
<div class="px-4 pt-2">
{#if incident.monitors.length > 0}
<div class="flex flex-wrap gap-2">
{#each incident.monitors as monitor}
<div class="tag-affected-text bg-secondary flex gap-x-2 rounded-md px-1 py-1 pr-2">
<div
class="bg-api-{monitor.impact_type.toLowerCase()} text-primary-foreground rounded px-1.5 py-1 text-xs font-semibold"
>
{monitor.impact_type}
</div>
{#if monitor.image}
<GMI src={monitor.image} classList="mt-1 h-4 w-4" />
{/if}
<div class="mt-0.5 font-medium">
{monitor.name}
</div>
</div>
{/each}
</div>
{/if}
<p class="text-muted-foreground my-3 text-xs font-semibold uppercase">
{l($page.data.lang, "Updates")}
</p>
{#if incident.comments.length > 0}
{#if incidentType == "INCIDENT"}
<ol class="relative mt-2 pl-14">
{#each incident.comments as comment}
<li class="relative border-l pb-4 pl-[4.5rem] last:border-0">
<div
class="bg-secondary absolute top-0 w-28 -translate-x-32 rounded border px-1.5 py-1 text-center text-xs font-semibold"
>
{l($page.data.lang, comment.state)}
</div>
<time class=" text-muted-foreground mb-1 text-sm leading-none font-medium">
{f(
new Date(comment.commented_at * 1000),
"MMMM do yyyy, h:mm:ss a",
$page.data.selectedLang,
$page.data.localTz
)}
</time>
<div class="mb-4 text-sm font-normal">
<div
class="kener-md prose prose-stone dark:prose-invert prose-code:rounded prose-code:py-[0.2rem] prose-code:font-mono prose-code:text-sm prose-code:font-normal prose-pre:bg-opacity-0 dark:prose-pre:bg-neutral-900 max-w-none"
>
{@html marked.parse(comment.comment)}
</div>
</div>
</li>
{/each}
</ol>
{:else if incidentType == "MAINTENANCE"}
<ol class="relative mt-2 pl-0">
{#each incident.comments as comment}
<li class="relative pb-2 last:border-0">
<time class=" text-muted-foreground mb-1 text-sm leading-none font-medium">
{f(
new Date(comment.commented_at * 1000),
"MMMM do yyyy, h:mm:ss a",
$page.data.selectedLang,
$page.data.localTz
)}
</time>
<div class="mb-2 text-sm font-normal">
<div
class="kener-md prose prose-stone dark:prose-invert prose-code:rounded prose-code:py-[0.2rem] prose-code:font-mono prose-code:text-sm prose-code:font-normal prose-pre:bg-opacity-0 dark:prose-pre:bg-neutral-900 max-w-none"
>
{@html marked.parse(comment.comment)}
</div>
</div>
</li>
{/each}
</ol>
{/if}
{:else}
<p class="text-sm font-medium">
{l($page.data.lang, "No Updates Yet")}
</p>
{/if}
</div>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
</div>
</div>
+9
View File
@@ -0,0 +1,9 @@
<script lang="ts">
import { page } from "$app/state";
let { data } = page;
</script>
<div class="mx-auto flex max-w-5xl justify-center px-4">
{@html data.footerHTML}
</div>
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
import { page } from "$app/state";
import * as NavigationMenu from "$lib/components/ui/navigation-menu/index.js";
import { navigationMenuTriggerStyle } from "$lib/components/ui/navigation-menu/navigation-menu-trigger.svelte";
let { data } = page;
const navItems: { name: string; url: string; iconURL: string }[] = data.navItems || [];
const { siteName, siteUrl, logo } = data;
</script>
<div class="fixed inset-x-0 top-0 z-50 py-2">
<div class="mx-auto max-w-5xl px-4">
<div class="bg-background flex items-center justify-between rounded-3xl border p-1">
<!-- Brand -->
<a
href={siteUrl}
class="{navigationMenuTriggerStyle()} hover:border-border border border-transparent text-xs hover:bg-transparent"
style="border-radius: var(--radius-3xl)"
>
{#if logo}
<img src={logo} alt={siteName} class="mr-2 h-6 w-6 rounded-full object-cover" />
{/if}
{siteName}
</a>
<!-- Nav Links -->
<NavigationMenu.Root>
<NavigationMenu.List>
{#each navItems as item}
<NavigationMenu.Item>
<NavigationMenu.Link>
{#snippet child()}
<a
href={item.url}
class="{navigationMenuTriggerStyle()} hover:border-border border border-transparent text-xs hover:bg-transparent"
target={item.url.startsWith("http") ? "_blank" : undefined}
rel={item.url.startsWith("http") ? "noopener noreferrer" : undefined}
style="border-radius: var(--radius-3xl)"
>
{#if item.iconURL}
<img src={item.iconURL} alt={item.name} class="mr-2 h-4 w-4" />
{/if}
{item.name}
</a>
{/snippet}
</NavigationMenu.Link>
</NavigationMenu.Item>
{/each}
</NavigationMenu.List>
</NavigationMenu.Root>
</div>
</div>
</div>
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
import { format } from "date-fns";
import { AreaChart, Area, LinearGradient } from "layerchart";
import { curveCatmullRom } from "d3-shape";
import { scaleTime } from "d3-scale";
import * as Chart from "$lib/components/ui/chart/index.js";
import type { TimestampStatusCount } from "$lib/server/types/db";
interface Props {
data: TimestampStatusCount[];
height?: number;
class?: string;
}
let { data, height = 128, class: className = "" }: Props = $props();
// Chart config
const chartConfig = {
avgLatency: {
label: "Avg Latency",
color: "var(--chart-1)"
}
} satisfies Chart.ChartConfig;
// Transform data for chart
let chartData = $derived.by(() => {
if (!data) return [];
return data
.filter((d) => d.avgLatency > 0)
.map((d) => ({
date: new Date(d.ts * 1000),
avgLatency: d.avgLatency
}));
});
</script>
<div class="{className} ">
{#if chartData.length > 0}
<Chart.Container config={chartConfig} class="w-full" style="height: {height}px;">
<AreaChart
data={chartData}
x="date"
xScale={scaleTime()}
y="avgLatency"
yDomain={[0, null]}
yNice
axis="x"
grid={false}
series={[
{
key: "avgLatency",
label: "Avg Latency",
color: "var(--color-avgLatency)"
}
]}
props={{
area: {
curve: curveCatmullRom,
"fill-opacity": 0.4,
line: { class: "stroke-1" }
},
xAxis: {
format: (d: Date) => format(d, "MMM d")
}
}}
>
{#snippet marks({ series, getAreaProps })}
{#each series as s, i (s.key)}
<LinearGradient stops={[s.color ?? "", "color-mix(in lch, " + s.color + " 10%, transparent)"]} vertical>
{#snippet children({ gradient })}
<Area {...getAreaProps(s, i)} fill={gradient} />
{/snippet}
</LinearGradient>
{/each}
{/snippet}
{#snippet tooltip()}
<Chart.Tooltip hideLabel>
{#snippet formatter({ value, name, item })}
<div class="flex w-full items-start gap-2">
<div
style="--color-bg: {item.color}; --color-border: {item.color};"
class="mt-0.5 size-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
></div>
<div class="flex flex-1 flex-col items-start justify-between gap-1 leading-none">
<span class="text-muted-foreground text-xs"
>{item.payload?.date ? format(item.payload.date, "MMM d") : ""}</span
>
<div class="flex items-center gap-2">
<span class="text-foreground font-mono font-medium tabular-nums">
{Math.round(Number(value))} ms
</span>
</div>
</div>
</div>
{/snippet}
</Chart.Tooltip>
{/snippet}
</AreaChart>
</Chart.Container>
{:else}
<div class="flex items-center justify-center" style="height: {height}px;">
<p class="text-muted-foreground text-sm">No latency data available</p>
</div>
{/if}
</div>
+153
View File
@@ -0,0 +1,153 @@
<script lang="ts">
import { format, formatDistanceStrict } from "date-fns";
import * as Item from "$lib/components/ui/item/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import ArrowRight from "@lucide/svelte/icons/arrow-right";
import * as Popover from "$lib/components/ui/popover/index.js";
import * as Avatar from "$lib/components/ui/avatar/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import STATUS_ICON from "$lib/icons";
interface MaintenanceMonitorImpact {
monitor_tag: string;
monitor_impact: string;
monitor_name: string;
monitor_image: string | null;
}
interface Maintenance {
id: number;
title: string;
description: string | null;
monitors: MaintenanceMonitorImpact[];
start_date_time: number;
end_date_time: number;
}
interface Props {
maintenance: Maintenance;
class?: string;
hideMonitors?: boolean;
}
let { maintenance, class: className = "", hideMonitors = false }: Props = $props();
const Icon = STATUS_ICON.MAINTENANCE;
// Get initials from monitor name for avatar fallback
function getInitials(name: string): string {
return name
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
// Check if maintenance is ongoing (current time is between start and end)
const isOngoing = $derived(() => {
const now = Date.now() / 1000;
return now >= maintenance.start_date_time && now <= maintenance.end_date_time;
});
// Calculate duration between start and end
const duration = $derived(() => {
const startDate = new Date(maintenance.start_date_time * 1000);
const endDate = new Date(maintenance.end_date_time * 1000);
return formatDistanceStrict(startDate, endDate);
});
// Truncate description to approximately 2-3 lines
function truncateDescription(text: string | null, maxLength: number = 150): string {
if (!text) return "";
if (text.length <= maxLength) return text;
return text.slice(0, maxLength).trim() + "...";
}
</script>
<Item.Root class="p-0 {className}">
<Item.Media>
<Icon class="stroke-maintenance size-6" />
</Item.Media>
<Item.Content>
<div class="flex items-center gap-2">
<Item.Title>{maintenance.title}</Item.Title>
{#if isOngoing()}
<Badge variant="outline" class="text-maintenance border-maintenance text-xs">In Progress</Badge>
{/if}
</div>
{#if maintenance.description}
<p class="text-muted-foreground mt-1 text-sm">
{truncateDescription(maintenance.description)}
</p>
{/if}
{#if maintenance.monitors && maintenance.monitors.length > 0 && !hideMonitors}
<div class="*:data-[slot=avatar]:ring-background my-2 flex -space-x-2 *:data-[slot=avatar]:ring-2">
{#each maintenance.monitors as monitor}
<Popover.Root>
<Popover.Trigger>
<Avatar.Root
class="bg-background border-maintenance size-8 cursor-pointer border-2 transition-transform duration-100 ease-in-out hover:scale-[1.1] hover:border"
>
{#if monitor.monitor_image}
<Avatar.Image src={monitor.monitor_image} alt={monitor.monitor_name} class="object-cover" />
{/if}
<Avatar.Fallback class="text-xs">{getInitials(monitor.monitor_name)}</Avatar.Fallback>
</Avatar.Root>
</Popover.Trigger>
<Popover.Content class="w-64">
<div class="flex flex-col gap-3">
<div class="flex items-center gap-3">
<Avatar.Root>
{#if monitor.monitor_image}
<Avatar.Image src={monitor.monitor_image} alt={monitor.monitor_name} />
{/if}
<Avatar.Fallback>{getInitials(monitor.monitor_name)}</Avatar.Fallback>
</Avatar.Root>
<div class="flex flex-col">
<span class="font-medium">{monitor.monitor_name}</span>
<span class="text-muted-foreground text-xs">{monitor.monitor_tag}</span>
</div>
</div>
<div class="flex items-center justify-between">
<Badge variant="outline" class="text-maintenance">MAINTENANCE</Badge>
<Button variant="outline" size="sm" href="/monitors/{monitor.monitor_tag}">
View Monitor
<ArrowRight class="ml-1 size-3" />
</Button>
</div>
</div>
</Popover.Content>
</Popover.Root>
{/each}
</div>
{/if}
<Item.Description>
<div class="mt-2 flex items-center justify-between text-xs font-medium">
<div class="rounded-full border px-3 py-2">
{format(new Date(maintenance.start_date_time * 1000), "PPp")}
</div>
<div class="relative flex-1 text-center">
<div class="border-maintenance absolute top-1/2 right-0 left-0 border-t border-dashed"></div>
<span class="bg-background relative z-10 px-2 py-1">{duration()}</span>
</div>
<div class="rounded-full border px-3 py-2">
{format(new Date(maintenance.end_date_time * 1000), "PPp")}
</div>
</div>
</Item.Description>
</Item.Content>
<Item.Actions>
<Button
variant="outline"
class="cursor-pointer rounded-full shadow-none"
href="/maintenances/{maintenance.id}"
size="icon"
>
<ArrowRight />
</Button>
</Item.Actions>
</Item.Root>
+155
View File
@@ -0,0 +1,155 @@
<script lang="ts">
import { onMount } from "svelte";
import * as Item from "$lib/components/ui/item/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
import * as Avatar from "$lib/components/ui/avatar/index.js";
import ICONS from "$lib/icons";
import type { StatusType } from "$lib/global-constants";
import { getNowTimestampUTC } from "$lib/client/datetime";
import StatusBarCalendar from "$lib/components/StatusBarCalendar.svelte";
import { page } from "$app/state";
import type { MonitorBarResponse, BarData } from "$lib/server/api-server/monitor-bar/get.js";
interface Props {
tag: string;
localTz?: string;
}
let { tag, localTz = "UTC" }: Props = $props();
let loading = $state(true);
let data = $state<MonitorBarResponse | null>(null);
let error = $state<string | null>(null);
const STATUS_ICON = {
UP: ICONS.UP,
DOWN: ICONS.DOWN,
DEGRADED: ICONS.DEGRADED,
MAINTENANCE: ICONS.MAINTENANCE,
NO_DATA: ICONS.MAINTENANCE
} as const;
const STATUS_STROKE = {
UP: "stroke-up",
DOWN: "stroke-down",
DEGRADED: "stroke-degraded",
MAINTENANCE: "stroke-maintenance",
NO_DATA: "stroke-muted-foreground"
} as const;
function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleDateString("en-US", {
day: "numeric",
month: "short",
year: "numeric",
timeZone: localTz
});
}
onMount(async () => {
try {
const utcTs = getNowTimestampUTC();
const response = await fetch(
`/dashboard-apis/monitor-bar?tag=${encodeURIComponent(tag)}&endOfDayTodayAtTz=${page.data.endOfDayTodayAtTz}`
);
if (!response.ok) {
throw new Error("Failed to fetch monitor data");
}
data = await response.json();
} catch (e) {
console.error("Failed to fetch monitor bar data:", e);
error = e instanceof Error ? e.message : "Unknown error";
} finally {
loading = false;
}
});
</script>
<div>
{#if loading}
<!-- Skeleton loader -->
<Item.Root>
<Item.Media variant="image">
<Skeleton class="size-8 rounded" />
</Item.Media>
<Item.Content>
<Skeleton class="mb-2 h-5 w-40" />
<Skeleton class="h-4 w-64" />
</Item.Content>
<Item.Content class="flex-none text-center">
<Skeleton class="h-8 w-24" />
</Item.Content>
<Item.Actions>
<Skeleton class="size-9" />
</Item.Actions>
</Item.Root>
<div class="mx-auto flex w-full flex-col gap-1 px-4">
<div class="flex justify-end overflow-hidden rounded-full">
{#each Array(54) as _, i}
<Skeleton
class="h-4 w-4 shrink-0 {i === 0 ? 'rounded-tl-full rounded-bl-full' : ''} {i === 53
? 'rounded-tr-full rounded-br-full'
: ''}"
/>
{/each}
</div>
<div class="flex justify-end">
<Skeleton class="h-3 w-32" />
</div>
</div>
{:else if error}
<!-- Error state -->
<div class="text-destructive p-4 text-center">
<p>Failed to load monitor: {error}</p>
</div>
{:else if data}
<!-- Loaded state -->
{@const StatusIcon = STATUS_ICON[data.currentStatus]}
<Item.Root>
<Item.Media variant="image">
<Avatar.Root class="size-10">
<Avatar.Image src={data.image} alt={data.name} class="border " />
<Avatar.Fallback>{data.name.charAt(1)}</Avatar.Fallback>
</Avatar.Root>
</Item.Media>
<Item.Content>
<Item.Title>{data.name}</Item.Title>
{#if data.description}
<Item.Description>{data.description}</Item.Description>
{/if}
</Item.Content>
<Item.Content class="flex-none text-center">
<Item.Title class="text-2xl">
<StatusIcon class={STATUS_STROKE[data.currentStatus]} />
<div class="flex flex-col items-start gap-1">
<span>{data.uptime}%</span>
<span class="text-muted-foreground text-xs">
{data.avgLatency}
</span>
</div>
</Item.Title>
</Item.Content>
<Item.Actions>
<Button size="icon" variant="outline" class="cursor-pointer rounded-full shadow-none" href="/monitors/{tag}">
<ICONS.ARROW_RIGHT />
</Button>
</Item.Actions>
</Item.Root>
<div class="mx-auto flex w-full flex-col gap-1 px-4">
<StatusBarCalendar data={data.uptimeData} monitorTag={tag} {localTz} barHeight={40} radius={8} />
<div class="flex justify-between">
<p class="text-muted-foreground text-xs font-medium">
{formatTimestamp(data.fromTimeStamp)}
</p>
<p class="text-muted-foreground text-xs font-medium">
{formatTimestamp(data.toTimeStamp)}
</p>
</div>
</div>
{/if}
</div>
+456
View File
@@ -0,0 +1,456 @@
<script lang="ts">
import { onMount } from "svelte";
import { isToday } from "date-fns";
import { fly } from "svelte/transition";
import { resolve } from "$app/paths";
import ChevronLeft from "lucide-svelte/icons/chevron-left";
import ChevronRight from "lucide-svelte/icons/chevron-right";
import { Button } from "$lib/components/ui/button/index.js";
import * as Card from "$lib/components/ui/card/index.js";
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
import MonitorDayDetail from "$lib/components/MonitorDayDetail.svelte";
import constants from "$lib/global-constants.js";
import { page } from "$app/state";
interface DayData {
timestamp: number;
cssClass: string;
status: string;
UP: number;
DOWN: number;
DEGRADED: number;
MAINTENANCE: number;
NO_DATA: number;
total: number;
}
interface MonitorData {
monitor: {
tag: string;
name: string;
description: string;
image: string;
monitor_type: string;
};
days: DayData[];
}
interface Props {
monitorTag: string;
localTz: string;
class?: string;
}
let { monitorTag, localTz, class: className = "" }: Props = $props();
// State
let loading = $state(true);
let monitorData = $state<MonitorData | null>(null);
// Daily detail dialog state
let dialogOpen = $state(false);
let selectedDay = $state<{
timestamp: number;
status: string;
} | null>(null);
// Calendar state
let now = new Date();
let selectedMonth = $state(now.getMonth());
let selectedYear = $state(now.getFullYear());
let slideDirection = $state(1);
const monthNames = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
];
const dayNames = ["M", "T", "W", "T", "F", "S", "S"];
// Format duration from minutes to human-readable string (e.g., "2h 30min")
function formatDurationFromMinutes(minutes: number): string {
if (minutes <= 0) return "";
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0 && mins > 0) return `${hours}h ${mins}min`;
if (hours > 0) return `${hours}h`;
return `${mins}min`;
}
// Create a lookup map for day status by date string (YYYY-MM-DD)
let dayStatusMap = $derived.by(() => {
const map = new Map<string, DayData>();
if (monitorData?.days) {
for (const day of monitorData.days) {
const date = new Date(day.timestamp * 1000);
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
map.set(dateStr, day);
}
}
return map;
});
// Navigation functions
function goToPreviousMonth() {
slideDirection = -1;
if (selectedMonth === 0) {
selectedMonth = 11;
selectedYear--;
} else {
selectedMonth--;
}
}
function goToNextMonth() {
slideDirection = 1;
if (selectedMonth === 11) {
selectedMonth = 0;
selectedYear++;
} else {
selectedMonth++;
}
}
// Helper functions for calendar
function daysInMonth(month: number, year: number): number {
return new Date(year, month + 1, 0).getDate();
}
function getMonthData(month: number, year: number) {
const days = daysInMonth(month, year);
const firstDayOfMonth = new Date(year, month, 1).getDay();
const offset = firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1;
const calendarDays: (number | null)[] = [];
for (let i = 0; i < offset; i++) {
calendarDays.push(null);
}
for (let i = 1; i <= days; i++) {
calendarDays.push(i);
}
const weeks: (number | null)[][] = [];
for (let i = 0; i < calendarDays.length; i += 7) {
weeks.push(calendarDays.slice(i, i + 7));
}
return {
name: monthNames[month],
year,
month,
weeks
};
}
// Get three months: current selected month, and 2 previous months
let months = $derived.by(() => {
const result = [];
let m = selectedMonth;
let y = selectedYear;
// Go back 2 months from selected
for (let i = 0; i < 2; i++) {
m--;
if (m < 0) {
m = 11;
y--;
}
}
// Now add 3 months starting from that point
for (let i = 0; i < 3; i++) {
result.push(getMonthData(m, y));
m++;
if (m > 11) {
m = 0;
y++;
}
}
return result;
});
// Get day status for a specific date
function getDayStatus(day: number, month: number, year: number) {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
return dayStatusMap.get(dateStr);
}
// Get CSS class for a day
function getDayCssClass(day: number | null, month: number, year: number): string {
if (day === null) return "";
const status = getDayStatus(day, month, year);
if (!status) return "bg-muted/30";
return mapCssClass(status.cssClass);
}
// Check if day is in the future
function isFutureDay(day: number, month: number, year: number): boolean {
const date = new Date(year, month, day);
const today = new Date();
today.setHours(0, 0, 0, 0);
return date > today;
}
// Fetch monitor uptime data
async function fetchMonitorData() {
loading = true;
try {
const response = await fetch(
`${resolve("/dashboard-apis/monitor-uptime")}?tag=${monitorTag}&days=90&localTz=${encodeURIComponent(localTz)}`
);
if (response.ok) {
monitorData = await response.json();
}
} catch (error) {
console.error("Failed to fetch monitor data:", error);
} finally {
loading = false;
}
}
// Handle day click
function handleDayClick(day: number, month: number, year: number) {
if (isFutureDay(day, month, year)) return;
const status = getDayStatus(day, month, year);
if (!status) return;
selectedDay = { timestamp: status.timestamp, status: status.status };
dialogOpen = true;
}
function handleDialogClose() {
selectedDay = null;
}
// Map cssClass from API to Tailwind/CSS classes
function mapCssClass(cssClass: string): string {
const match = cssClass.match(/^api-(\w+)(?:-(\d+))?$/);
if (!match) return "bg-muted";
const status = match[1];
const percentage = match[2];
if (percentage) {
return `bg-${status}-${percentage}`;
}
switch (status) {
case constants.UP.toLowerCase():
return "bg-up";
case constants.DOWN.toLowerCase():
return "bg-down";
case constants.DEGRADED.toLowerCase():
return "bg-degraded";
case constants.MAINTENANCE.toLowerCase():
return "bg-maintenance";
case constants.NO_DATA.toLowerCase():
return "bg-muted";
default:
return "bg-muted";
}
}
onMount(() => {
fetchMonitorData();
});
</script>
{#if loading}
<!-- Loading skeleton -->
<Card.Root class="bg-background rounded-3xl {className}">
<Card.Content class="p-6">
<div class="flex justify-between gap-4">
{#each [1, 2, 3] as i (i)}
<div class="flex-1">
<Skeleton class="mb-4 h-6 w-24" />
<div class="grid grid-cols-7 gap-1">
{#each Array(35) as _, j (j)}
<Skeleton class="aspect-square w-full rounded" />
{/each}
</div>
</div>
{/each}
</div>
</Card.Content>
</Card.Root>
{:else if monitorData}
<!-- Calendar View -->
<Card.Root class="bg-background overflow-hidden rounded-3xl shadow-none {className}">
<Card.Content class="p-6">
<!-- Navigation -->
<div class="mb-4 flex items-center justify-between">
<Button variant="outline" size="icon-sm" onclick={goToPreviousMonth} class="rounded-btn">
<ChevronLeft />
</Button>
<span class="text-muted-foreground text-sm">
{monthNames[selectedMonth]}
{selectedYear}
</span>
<Button variant="outline" size="icon-sm" onclick={goToNextMonth} class="rounded-btn">
<ChevronRight />
</Button>
</div>
<!-- Three month calendar grid -->
<div class="relative overflow-hidden px-2">
{#key `${selectedMonth}-${selectedYear}`}
<div
class="flex justify-between gap-4"
in:fly={{ x: slideDirection * 100, duration: 300 }}
out:fly|local={{ x: slideDirection * -100, duration: 300 }}
style="position: relative;"
onoutrostart={(e) => (e.currentTarget.style.position = "absolute")}
>
{#each months as monthData, monthIndex (monthData.month + "-" + monthData.year)}
<div class="flex-1">
<!-- Month header -->
<div class="mb-2 text-center text-sm font-semibold">
{monthData.name} '{String(monthData.year).slice(-2)}
</div>
<!-- Day names header -->
<div class="mb-1 grid grid-cols-7 gap-1">
{#each dayNames as dayName, i (i)}
<div class="text-muted-foreground flex h-6 items-center justify-center text-xs font-medium">
{dayName}
</div>
{/each}
</div>
<!-- Calendar days -->
<div class="grid grid-cols-7 gap-1 pb-1">
{#each monthData.weeks as week, weekIndex (weekIndex)}
{#each week as day, dayIndex (dayIndex)}
{#if day === null}
<div class="aspect-square w-full"></div>
{:else}
{@const isFuture = isFutureDay(day, monthData.month, monthData.year)}
{@const dayStatus = getDayStatus(day, monthData.month, monthData.year)}
{@const hasData = !!dayStatus}
{#if hasData && !isFuture}
<Tooltip.Root>
<Tooltip.Trigger class="tooltip-no-caret w-full">
<button
class="group relative flex aspect-square w-full cursor-pointer items-center justify-center rounded transition-all hover:scale-105 hover:ring-2 hover:ring-offset-1 {getDayCssClass(
day,
monthData.month,
monthData.year
)}"
onclick={() => handleDayClick(day, monthData.month, monthData.year)}
aria-label={`${monthData.name} ${day} - ${dayStatus.status}`}
>
<span
class="text-xs font-medium opacity-0 transition-opacity group-hover:opacity-100 {dayStatus.status ===
'DOWN' || dayStatus.status === 'MAINTENANCE'
? 'text-white'
: ''}"
>
{day}
</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content class="" arrowClasses="bg-foreground">
<div class="space-y-1 font-medium">
{#if dayStatus.DOWN > 0}
<div class="flex items-center gap-1.5">
<span class="text-down">
{dayStatus.status} for {formatDurationFromMinutes(dayStatus.DOWN)}
</span>
</div>
{:else if dayStatus.DEGRADED > 0}
<div class="flex items-center gap-1.5">
<span class="text-degraded">
{dayStatus.status} for {formatDurationFromMinutes(dayStatus.DEGRADED)}
</span>
</div>
{:else if dayStatus.MAINTENANCE > 0}
<div class="flex items-center gap-1.5">
<span class="text-maintenance">
{dayStatus.status} for {formatDurationFromMinutes(dayStatus.MAINTENANCE)}
</span>
</div>
{:else if dayStatus.UP > 0}
<div class="flex items-center gap-1.5">
<span class="text-up">Status OK</span>
</div>
{:else}
<div class="flex items-center gap-1.5">
<span class="text-muted-foreground">No Data</span>
</div>
{/if}
</div>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<button
class="group relative flex aspect-square w-full items-center justify-center rounded transition-all
{isFuture ? 'cursor-not-allowed opacity-30' : 'cursor-default'}
{getDayCssClass(day, monthData.month, monthData.year)}"
disabled={true}
aria-label={`${monthData.name} ${day}`}
>
<span class="text-xs font-medium opacity-0">
{day}
</span>
</button>
{/if}
{/if}
{/each}
{/each}
</div>
</div>
{/each}
</div>
{/key}
</div>
<!-- Legend -->
<div class="mt-6 flex flex-wrap items-center justify-center gap-4 border-t pt-4">
<div class="flex items-center gap-2">
<div class="bg-up h-4 w-4 rounded"></div>
<span class="text-xs font-medium">{constants.UP}</span>
</div>
<div class="flex items-center gap-2">
<div class="bg-degraded h-4 w-4 rounded"></div>
<span class="text-xs font-medium">{constants.DEGRADED}</span>
</div>
<div class="flex items-center gap-2">
<div class="bg-down h-4 w-4 rounded"></div>
<span class="text-xs font-medium">{constants.DOWN}</span>
</div>
<div class="flex items-center gap-2">
<div class="bg-maintenance h-4 w-4 rounded"></div>
<span class="text-xs font-medium">{constants.MAINTENANCE}</span>
</div>
<div class="flex items-center gap-2">
<div class="bg-muted h-4 w-4 rounded"></div>
<span class="text-xs font-medium">{constants.NO_DATA}</span>
</div>
</div>
</Card.Content>
</Card.Root>
<!-- Day Detail Dialog -->
<MonitorDayDetail bind:open={dialogOpen} {monitorTag} {localTz} {selectedDay} onClose={handleDialogClose} />
{:else}
<!-- Error state -->
<Card.Root class="rounded-3xl {className}">
<Card.Content class="py-12 text-center">
<p class="text-muted-foreground">Failed to load calendar data. Please try again.</p>
<Button class="mt-4" onclick={fetchMonitorData}>Retry</Button>
</Card.Content>
</Card.Root>
{/if}
+451
View File
@@ -0,0 +1,451 @@
<script lang="ts">
import { onMount } from "svelte";
import { format } from "date-fns";
import { resolve } from "$app/paths";
import TrendingUp from "lucide-svelte/icons/trending-up";
import Clock from "lucide-svelte/icons/clock";
import Activity from "lucide-svelte/icons/activity";
import { Button } from "$lib/components/ui/button/index.js";
import * 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 IncidentItem from "$lib/components/IncidentItem.svelte";
import MaintenanceItem from "$lib/components/MaintenanceItem.svelte";
import * as Chart from "$lib/components/ui/chart/index.js";
import type { IncidentForMonitorList, MaintenanceEventsMonitorList } from "$lib/server/types/db";
interface DayDetailData {
minutes: Array<{
timestamp: number;
status: string;
}>;
uptime: string;
}
interface DayLatencyData {
minutes: Array<{
timestamp: number;
latency: number;
}>;
avgLatency: string;
}
interface Props {
open: boolean;
monitorTag: string;
selectedDay: {
timestamp: number;
status: string;
} | null;
}
let { open = $bindable(), monitorTag, selectedDay }: Props = $props();
let loading = $state(false);
let latencyLoading = $state(false);
let incidentsLoading = $state(false);
let maintenancesLoading = $state(false);
let activeView = $state<"status" | "latency" | "incidents" | "maintenances">("status");
let dayDetailData = $state<DayDetailData | null>(null);
let dayLatencyData = $state<DayLatencyData | null>(null);
let dayIncidentsData = $state<IncidentForMonitorList[]>([]);
let dayMaintenancesData = $state<MaintenanceEventsMonitorList[]>([]);
// Chart config for latency
const chartConfig = {
latency: {
label: "Latency",
color: "var(--chart-1)"
}
} satisfies Chart.ChartConfig;
// Transform latency data for chart
let chartData = $derived.by(() => {
if (!dayLatencyData?.minutes) return [];
return dayLatencyData.minutes
.filter((d) => d.latency > 0)
.map((d) => ({
date: new Date(d.timestamp * 1000),
latency: d.latency
}));
});
// Fetch day detail data
async function fetchDayDetail() {
if (!selectedDay) return;
loading = true;
dayDetailData = null;
try {
const response = await fetch(resolve("/dashboard-apis/monitor-day-status"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tag: monitorTag,
dayTimestamp: selectedDay.timestamp,
startOfDayTodayAtTz: selectedDay.timestamp,
nowAtTz: Math.min(selectedDay.timestamp + 86400 - 60, page.data.nowAtTz)
})
});
if (response.ok) {
dayDetailData = await response.json();
}
} catch (error) {
console.error("Failed to fetch day detail:", error);
} finally {
loading = false;
}
}
// Fetch day latency data
async function fetchDayLatency() {
if (!selectedDay) return;
latencyLoading = true;
dayLatencyData = null;
try {
const response = await fetch(resolve("/dashboard-apis/monitor-day-latency"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tag: monitorTag,
startOfDayTodayAtTz: selectedDay.timestamp,
nowAtTz: Math.min(selectedDay.timestamp + 86400 - 60, page.data.nowAtTz)
})
});
if (response.ok) {
dayLatencyData = await response.json();
}
} catch (error) {
console.error("Failed to fetch day latency:", error);
} finally {
latencyLoading = false;
}
}
//// Fetch day incident data
async function fetchDayIncidents() {
if (!selectedDay) return;
incidentsLoading = true;
dayIncidentsData = [];
try {
const response = await fetch(resolve("/dashboard-apis/monitor-day-incidents"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tag: monitorTag,
startOfDayTodayAtTz: selectedDay.timestamp,
nowAtTz: Math.min(selectedDay.timestamp + 86400 - 60, page.data.nowAtTz)
})
});
if (response.ok) {
const result = await response.json();
dayIncidentsData = result.incidents;
}
} catch (error) {
console.error("Failed to fetch day incidents:", error);
} finally {
incidentsLoading = false;
}
}
// Fetch day maintenance data
async function fetchDayMaintenances() {
if (!selectedDay) return;
maintenancesLoading = true;
dayMaintenancesData = [];
try {
const response = await fetch(resolve("/dashboard-apis/monitor-day-maintenances"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tag: monitorTag,
startOfDayTodayAtTz: selectedDay.timestamp,
nowAtTz: Math.min(selectedDay.timestamp + 86400 - 60, page.data.nowAtTz)
})
});
if (response.ok) {
const result = await response.json();
dayMaintenancesData = result.maintenances;
}
} catch (error) {
console.error("Failed to fetch day maintenances:", error);
} finally {
maintenancesLoading = false;
}
}
// Format date for display
function formatDate(timestamp: number): string {
return format(new Date(timestamp * 1000), "EEEE, MMMM do, yyyy");
}
function formatTime(timestamp: number): string {
return format(new Date(timestamp * 1000), "HH:mm");
}
// Fetch data when dialog opens with a new day
$effect(() => {
if (open && selectedDay) {
fetchDayDetail();
fetchDayLatency();
fetchDayIncidents();
fetchDayMaintenances();
}
});
</script>
<Dialog.Root bind:open>
<Dialog.Overlay class="backdrop-blur-[2px]" />
<Dialog.Content class="max-h-[80vh] overflow-y-auto rounded-3xl sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<Activity class="h-5 w-5" />
{selectedDay ? formatDate(selectedDay.timestamp) : ""}
</Dialog.Title>
<Dialog.Description>Minute-by-minute status data for this day</Dialog.Description>
</Dialog.Header>
<Tabs.Root value={activeView} class="bg-background w-full overflow-hidden rounded-3xl border">
<Tabs.List class="h-auto w-full justify-end rounded-none px-2 py-2">
<Tabs.Trigger value="status" class="rounded-3xl py-2">Status</Tabs.Trigger>
<Tabs.Trigger value="latency" class="rounded-3xl py-2">Latency</Tabs.Trigger>
<Tabs.Trigger value="incidents" class="rounded-3xl py-2">Incidents</Tabs.Trigger>
<Tabs.Trigger value="maintenances" class="rounded-3xl py-2">Maintenances</Tabs.Trigger>
</Tabs.List>
<!-- status view -->
<Tabs.Content value="status" class="p-4">
{#if loading}
<!-- Loading state -->
<div class="space-y-4 py-4">
<div class="flex items-center justify-between">
<Skeleton class="h-6 w-32" />
<Skeleton class="h-6 w-24" />
</div>
<div class="flex flex-wrap">
<LoaderBoxes />
</div>
</div>
{:else if dayDetailData}
<div class="space-y-4">
<!-- Minute grid -->
<div>
<div class="text-muted-foreground mb-2 flex items-center justify-between text-sm font-medium">
<p class="">Per-Minute Status</p>
<div class="flex items-center gap-1">
<TrendingUp class="h-3 w-3" />
{dayDetailData.uptime}%
</div>
</div>
<div class="flex flex-wrap gap-0.5">
{#each dayDetailData.minutes as minute, index (minute.timestamp)}
<div class="group relative">
<div
class="h-2.5 w-2.5 cursor-pointer bg-{minute.status.toLowerCase()} rounded-[1px] transition-opacity hover:opacity-75"
title="{formatTime(minute.timestamp)} - {minute.status} - {index}"
></div>
<div
class="bg-popover text-popover-foreground pointer-events-none absolute bottom-full left-1/2 z-50 mb-1 hidden -translate-x-1/2 rounded border px-2 py-1 text-xs whitespace-nowrap shadow-md group-hover:block"
>
{formatTime(minute.timestamp)}
</div>
</div>
{/each}
</div>
<div class="text-muted-foreground mt-2 flex justify-between text-xs">
<span>00:00</span>
<span>06:00</span>
<span>12:00</span>
<span>18:00</span>
<span>23:59</span>
</div>
</div>
</div>
{:else}
<div class="py-8 text-center">
<p class="text-muted-foreground">Failed to load data</p>
</div>
{/if}
</Tabs.Content>
<Tabs.Content value="latency" class="p-4">
{#if latencyLoading}
<!-- Loading state -->
<div class="space-y-4 py-4">
<div class="flex items-center justify-between">
<Skeleton class="h-6 w-32" />
<Skeleton class="h-6 w-24" />
</div>
<Skeleton class="h-64 w-full" />
</div>
{:else if dayLatencyData && chartData.length > 0}
<div class="space-y-4">
<!-- Average latency -->
<div class="text-muted-foreground mb-2 flex items-center justify-between text-sm font-medium">
<p>Latency Over Time</p>
<div class="flex items-center gap-1">
<Clock class="h-3 w-3" />
{dayLatencyData.avgLatency} Avg
</div>
</div>
<!-- Latency chart -->
<Chart.Container config={chartConfig} class="w-full" style="height: 256px;">
<AreaChart
data={chartData}
x="date"
xScale={scaleTime()}
y="latency"
yDomain={[0, null]}
yNice
axis="x"
grid={false}
series={[
{
key: "latency",
label: "Latency",
color: "var(--color-latency)"
}
]}
props={{
area: {
curve: curveCatmullRom,
"fill-opacity": 0.4,
line: { class: "stroke-1" }
},
xAxis: {
format: (d: Date) => format(d, "HH:mm")
}
}}
>
{#snippet marks({ series, getAreaProps })}
{#each series as s, i (s.key)}
<LinearGradient
stops={[s.color ?? "", "color-mix(in lch, " + s.color + " 10%, transparent)"]}
vertical
>
{#snippet children({ gradient })}
<Area {...getAreaProps(s, i)} fill={gradient} />
{/snippet}
</LinearGradient>
{/each}
{/snippet}
{#snippet tooltip()}
<Chart.Tooltip hideLabel>
{#snippet formatter({ value, name, item })}
<div class="flex w-full items-start gap-2">
<div
style="--color-bg: {item.color}; --color-border: {item.color};"
class="mt-0.5 size-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
></div>
<div class="flex flex-1 flex-col items-start justify-between gap-1 leading-none">
<span class="text-muted-foreground text-xs"
>{item.payload?.date ? format(item.payload.date, "HH:mm") : ""}</span
>
<div class="flex items-center gap-2">
<span class="text-foreground font-mono font-medium tabular-nums">
{Math.round(Number(value))} ms
</span>
</div>
</div>
</div>
{/snippet}
</Chart.Tooltip>
{/snippet}
</AreaChart>
</Chart.Container>
</div>
{:else if dayLatencyData && chartData.length === 0}
<div class="py-8 text-center">
<p class="text-muted-foreground">No latency data available for this day</p>
</div>
{:else}
<div class="py-8 text-center">
<p class="text-muted-foreground">Failed to load latency data</p>
</div>
{/if}
</Tabs.Content>
<Tabs.Content value="incidents" class="p-4">
{#if incidentsLoading}
<!-- Loading state -->
<div class="space-y-4 py-4">
<div class="flex items-center justify-between">
<Skeleton class="h-6 w-32" />
<Skeleton class="h-6 w-24" />
</div>
<Skeleton class="h-64 w-full" />
</div>
{:else if dayIncidentsData.length > 0}
<div class="space-y-4">
<!-- Incident list -->
<div>
<div class="text-muted-foreground mb-2 flex items-center justify-between text-sm font-medium">
<p class="">Incidents</p>
<div class="flex items-center gap-1">
<Clock class="h-3 w-3" />
{dayIncidentsData.length} Total
</div>
</div>
<div class="scrollbar-hidden flex max-h-100 flex-col gap-4 overflow-y-auto">
{#each dayIncidentsData as incident (incident.id)}
<div class="border-b pb-5 last:border-b-0">
<IncidentItem {incident} hideMonitors={true} />
</div>
{/each}
</div>
</div>
</div>
{:else}
<div class="py-8 text-center">
<p class="text-muted-foreground">No incidents for this day</p>
</div>
{/if}
</Tabs.Content>
<Tabs.Content value="maintenances" class="p-4">
{#if maintenancesLoading}
<!-- Loading state -->
<div class="space-y-4 py-4">
<div class="flex items-center justify-between">
<Skeleton class="h-6 w-32" />
<Skeleton class="h-6 w-24" />
</div>
<Skeleton class="h-64 w-full" />
</div>
{:else if dayMaintenancesData.length > 0}
<div class="space-y-4">
<!-- Maintenance list -->
<div>
<div class="text-muted-foreground mb-2 flex items-center justify-between text-sm font-medium">
<p class="">Maintenances</p>
<div class="flex items-center gap-1">
<Clock class="h-3 w-3" />
{dayMaintenancesData.length} Total
</div>
</div>
<div class="scrollbar-hidden flex max-h-100 flex-col gap-4 overflow-y-auto">
{#each dayMaintenancesData as maintenance (maintenance.id)}
<div class="border-b pb-5 last:border-b-0">
<MaintenanceItem {maintenance} hideMonitors={true} />
</div>
{/each}
</div>
</div>
</div>
{:else}
<div class="py-8 text-center">
<p class="text-muted-foreground">No maintenances for this day</p>
</div>
{/if}
</Tabs.Content>
</Tabs.Root>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,249 @@
<script lang="ts">
import { onMount } from "svelte";
import { format } from "date-fns";
import { resolve } from "$app/paths";
import * as Chart from "$lib/components/ui/chart/index.js";
import * as Card from "$lib/components/ui/card/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import ChevronDown from "@lucide/svelte/icons/chevron-down";
import Loader from "lucide-svelte/icons/loader";
import { AreaChart, Area, LinearGradient } from "layerchart";
import { curveCatmullRom } from "d3-shape";
interface Props {
monitorTag: string;
localTz: string;
class?: string;
}
let { monitorTag, localTz, class: className = "" }: Props = $props();
interface TimeRangeOption {
label: string;
value: string;
}
const timeRanges: TimeRangeOption[] = [
{ label: "Last Hour", value: "1h" },
{ label: "Last 3 Hours", value: "3h" },
{ label: "Last 6 Hours", value: "6h" },
{ label: "Last 12 Hours", value: "12h" },
{ label: "Last 24 Hours", value: "24h" },
{ label: "Last 48 Hours", value: "48h" },
{ label: "Last 7 Days", value: "7d" },
{ label: "Last 14 Days", value: "14d" },
{ label: "Last 30 Days", value: "30d" }
];
interface ChartDataPoint {
timestamp: number;
avgLatency: number | null;
minLatency: number | null;
maxLatency: number | null;
time: string;
}
// State
let loading = $state(true);
let chartData = $state<ChartDataPoint[]>([]);
let selectedRange = $state("24h");
let rangeLabel = $state("Last 24 Hours");
let avgInterval = $state(30);
let error = $state<string | null>(null);
// Chart config
const chartConfig = {
avgLatency: {
label: "Avg Latency",
color: "var(--chart-1)"
}
} satisfies Chart.ChartConfig;
// Fetch chart data
async function fetchChartData(range: string) {
loading = true;
error = null;
try {
const response = await fetch(
`${resolve("/dashboard-apis/monitor-latency-chart")}?tag=${monitorTag}&range=${range}&localTz=${encodeURIComponent(localTz)}`
);
if (!response.ok) {
throw new Error("Failed to fetch latency data");
}
const result = await response.json();
// Transform data for chart - filter out null values and add formatted time
chartData = result.data
.filter((d: ChartDataPoint) => d.avgLatency !== null)
.map((d: ChartDataPoint) => ({
...d,
time: formatTimestamp(d.timestamp, range)
}));
rangeLabel = result.rangeLabel;
avgInterval = result.avgInterval;
} catch (e) {
console.error("Failed to fetch latency chart data:", e);
error = e instanceof Error ? e.message : "Failed to load data";
} finally {
loading = false;
}
}
// Format timestamp based on range
function formatTimestamp(timestamp: number, range: string): string {
const date = new Date(timestamp * 1000);
if (range === "1h" || range === "3h" || range === "6h") {
return format(date, "HH:mm");
} else if (range === "12h" || range === "24h" || range === "48h") {
return format(date, "MMM d, HH:mm");
} else {
return format(date, "MMM d");
}
}
// Format x-axis labels
function formatXAxis(value: string): string {
// Shorten the time display for x-axis
if (value.includes(",")) {
const parts = value.split(", ");
return parts[1] || parts[0]; // Show time part if available
}
return value;
}
// Handle range change
function handleRangeChange(range: string) {
selectedRange = range;
fetchChartData(range);
}
onMount(() => {
fetchChartData(selectedRange);
});
</script>
<Card.Root class="bg-background overflow-hidden rounded-3xl shadow-none {className}">
<Card.Header class="flex flex-row items-center justify-between pb-2">
<div>
<Card.Title class="text-base font-medium">Latency Over Time</Card.Title>
<Card.Description class="text-xs">
{#if avgInterval >= 60}
Averaged every {avgInterval / 60} hour{avgInterval / 60 > 1 ? "s" : ""}
{:else}
Averaged every {avgInterval} minute{avgInterval > 1 ? "s" : ""}
{/if}
</Card.Description>
</div>
<DropdownMenu.Root>
<DropdownMenu.Trigger class="cursor-pointer">
{#snippet child({ props })}
<Button {...props} variant="outline" class="rounded-btn cursor-pointer gap-1 text-xs" size="sm">
{rangeLabel}
<ChevronDown class="size-3" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Label>Select Range</DropdownMenu.Label>
<DropdownMenu.Group>
{#each timeRanges as range}
<DropdownMenu.Item
class="cursor-pointer text-xs {selectedRange === range.value ? 'bg-secondary' : ''}"
onclick={() => handleRangeChange(range.value)}
>
{range.label}
{#if loading && selectedRange === range.value}
<Loader class="ml-2 size-3 animate-spin" />
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Card.Header>
<Card.Content class="pt-0">
{#if loading}
<div class="flex h-50 items-center justify-center">
<Skeleton class="h-full w-full rounded-lg" />
</div>
{:else if error}
<div class="flex h-50 items-center justify-center">
<p class="text-muted-foreground text-sm">{error}</p>
</div>
{:else if chartData.length === 0}
<div class="flex h-50 items-center justify-center">
<p class="text-muted-foreground text-sm">No latency data available for this period</p>
</div>
{:else}
<Chart.Container config={chartConfig} class="h-50 w-full">
<AreaChart
data={chartData}
x="time"
y="avgLatency"
axis="x"
grid={false}
series={[
{
key: "avgLatency",
label: "Avg Latency",
color: "var(--color-avgLatency)"
}
]}
props={{
area: {
curve: curveCatmullRom,
"fill-opacity": 0.4,
line: { class: "stroke-1" }
},
xAxis: {
format: (d: string) => formatXAxis(d),
ticks: Math.min(chartData.length, 6)
},
tooltip: {
item: {
format: (value: number) => `${Math.round(value)} ms`
}
}
}}
>
{#snippet marks({ series, getAreaProps })}
{#each series as s, i (s.key)}
<LinearGradient stops={[s.color ?? "", "color-mix(in lch, " + s.color + " 10%, transparent)"]} vertical>
{#snippet children({ gradient })}
<Area {...getAreaProps(s, i)} fill={gradient} />
{/snippet}
</LinearGradient>
{/each}
{/snippet}
{#snippet tooltip()}
<Chart.Tooltip hideLabel>
{#snippet formatter({ value, name, item })}
<div class="flex w-full items-start gap-2">
<div
style="--color-bg: {item.color}; --color-border: {item.color};"
class="mt-0.5 size-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
></div>
<div class="flex flex-1 flex-col items-start justify-between gap-1 leading-none">
<span class="text-muted-foreground text-xs">{item.payload?.time}</span>
<div class="flex items-center gap-2">
<span class="text-foreground font-mono font-medium tabular-nums">
{Math.round(Number(value))} ms
</span>
</div>
</div>
</div>
{/snippet}
</Chart.Tooltip>
{/snippet}
</AreaChart>
</Chart.Container>
{/if}
</Card.Content>
</Card.Root>
+182
View File
@@ -0,0 +1,182 @@
<script lang="ts">
import { onMount } from "svelte";
import { resolve } from "$app/paths";
import * as Card from "$lib/components/ui/card/index.js";
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import StatusBarCalendar from "$lib/components/StatusBarCalendar.svelte";
import LatencyTrendChart from "$lib/components/LatencyTrendChart.svelte";
import { page } from "$app/state";
import ChevronDown from "@lucide/svelte/icons/chevron-down";
import type { MonitorBarResponse } from "$lib/server/api-server/monitor-bar/get";
import { Button } from "$lib/components/ui/button";
interface Props {
monitorTag: string;
class?: string;
}
let { monitorTag, class: className = "" }: Props = $props();
// State
let loading = $state(true);
let overviewData = $state<MonitorBarResponse | null>(null);
let error = $state<string | null>(null);
const localTz = $derived(page.data.localTz || "UTC");
// Day range options
const dayOptions = [
{ days: 90, text: "90 Days" },
{ days: 30, text: "30 Days" },
{ days: 7, text: "7 Days" },
{ days: 1, text: "Today" }
];
let selectedDayIndex = $state(0);
// Display values from API response (already formatted as strings)
let displayUptime = $derived(overviewData?.uptime ?? "--");
let displayAvgLatency = $derived(overviewData?.avgLatency ?? "--");
// Data for calendar/chart comes directly from API
let displayData = $derived(overviewData?.uptimeData ?? []);
function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleDateString("en-US", {
day: "numeric",
month: "short",
year: "numeric",
timeZone: localTz
});
}
// Fetch data with days parameter
async function fetchData(days: number) {
loading = true;
error = null;
try {
const url = `${resolve("/dashboard-apis/monitor-bar")}?tag=${monitorTag}&endOfDayTodayAtTz=${page.data.endOfDayTodayAtTz}&days=${days}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error("Failed to fetch monitor data");
}
overviewData = await response.json();
} catch (e) {
console.error("Failed to fetch monitor data:", e);
error = e instanceof Error ? e.message : "Failed to load data";
} finally {
loading = false;
}
}
// Handle dropdown selection
function handleDayChange(index: number) {
if (index !== selectedDayIndex) {
selectedDayIndex = index;
fetchData(dayOptions[index].days);
}
}
onMount(() => {
fetchData(dayOptions[selectedDayIndex].days);
});
</script>
<Card.Root class="bg-background rounded-3xl shadow-none {className}">
<Card.Header class="pb-2">
<div class="flex items-center justify-between">
<div>
<Card.Title class="text-base font-medium">{dayOptions[selectedDayIndex].text} Overview</Card.Title>
<Card.Description class="text-xs">Status history and latency trend</Card.Description>
</div>
{#if !loading && overviewData}
<DropdownMenu.Root>
<DropdownMenu.Trigger class="cursor-pointer">
{#snippet child({ props })}
<Button {...props} variant="outline" class="rounded-btn cursor-pointer gap-1 text-xs" size="sm">
{dayOptions[selectedDayIndex].text}
<ChevronDown class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Label>Select Range</DropdownMenu.Label>
<DropdownMenu.Group>
{#each dayOptions as option, i}
<DropdownMenu.Item
class="cursor-pointer text-xs {selectedDayIndex === i ? 'bg-secondary' : ''}"
onclick={() => handleDayChange(i)}
>
{option.text}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</div>
</Card.Header>
<Card.Content class="space-y-4">
{#if loading}
<!-- Loading skeleton -->
<div class="space-y-4">
<div class="flex gap-4">
<Skeleton class="h-16 flex-1 rounded-lg" />
<Skeleton class="h-16 flex-1 rounded-lg" />
</div>
<Skeleton class="h-10 w-full rounded-lg" />
<div class="flex justify-between">
<Skeleton class="h-4 w-24" />
<Skeleton class="h-4 w-24" />
</div>
<Skeleton class="h-32 w-full rounded-lg" />
</div>
{:else if error}
<!-- Error state -->
<div class="py-12 text-center">
<p class="text-muted-foreground">{error}</p>
</div>
{:else if overviewData}
<!-- Stats Summary -->
<div class="flex gap-4">
<div class="flex flex-1 flex-col items-start gap-1">
<p class="text-2xl font-semibold">{displayUptime}%</p>
<p class="text-muted-foreground text-sm">Uptime</p>
</div>
<div class="flex flex-1 flex-col items-end gap-1">
<p class="text-2xl font-semibold">{displayAvgLatency}</p>
<p class="text-muted-foreground text-sm">Avg Latency</p>
</div>
</div>
<!-- Status Bar Calendar -->
<StatusBarCalendar data={displayData} {monitorTag} {localTz} barHeight={40} radius={8} />
<!-- Date labels -->
<div class="flex justify-between">
<p class="text-muted-foreground text-xs font-medium">
{#if displayData.length > 0}
{formatTimestamp(displayData[0].ts)}
{/if}
</p>
<p class="text-muted-foreground text-xs font-medium">
{#if displayData.length > 0}
{formatTimestamp(displayData[displayData.length - 1].ts)}
{/if}
</p>
</div>
<!-- Latency Chart -->
<div class="pt-2">
<p class="text-muted-foreground mb-2 text-xs font-medium">Latency Trend (Daily Average)</p>
<LatencyTrendChart data={displayData} height={128} />
</div>
{/if}
</Card.Content>
</Card.Root>
+86
View File
@@ -0,0 +1,86 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button/index.js";
import * as Table from "$lib/components/ui/table/index.js";
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
import type { MonitorTableRow } from "$lib/types/common";
import { onMount } from "svelte";
import ArrowRight from "@lucide/svelte/icons/arrow-right";
interface Props {
monitorTags: string[];
nowTimestamp: number;
}
let { monitorTags, nowTimestamp }: Props = $props();
let loading = $state(true);
let monitorData = $state<MonitorTableRow[]>([]);
onMount(async () => {
try {
const response = await fetch("/dashboard-apis/monitor-data", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ monitor_tags: monitorTags, now_ts: nowTimestamp })
});
if (response.ok) {
monitorData = await response.json();
}
} catch (e) {
console.error("Failed to fetch monitor data:", e);
} finally {
loading = false;
}
});
</script>
<div class="overflow-hidden rounded-3xl border">
<Table.Root class="ktable">
<Table.Header class="h-12">
<Table.Row>
<Table.Head class="w-40">Name</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Response Time</Table.Head>
<Table.Head class="text-center">24h</Table.Head>
<Table.Head class="text-center">7d</Table.Head>
<Table.Head class="text-center">30d</Table.Head>
<Table.Head class="text-center">90d</Table.Head>
<Table.Head class="text-right"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if loading}
{#each Array(monitorTags.length) as _, index}
<Table.Row>
<Table.Cell class="font-medium">
<Skeleton class="h-4 w-24" />
</Table.Cell>
<Table.Cell><Skeleton class="h-4 w-16" /></Table.Cell>
<Table.Cell><Skeleton class="h-4 w-16" /></Table.Cell>
<Table.Cell><Skeleton class="mx-auto h-4 w-8" /></Table.Cell>
<Table.Cell><Skeleton class="mx-auto h-4 w-8" /></Table.Cell>
<Table.Cell><Skeleton class="mx-auto h-4 w-8" /></Table.Cell>
<Table.Cell><Skeleton class="mx-auto h-4 w-8" /></Table.Cell>
<Table.Cell><Skeleton class="h-4 w-16" /></Table.Cell>
</Table.Row>
{/each}
{:else}
{#each monitorData as monitor}
<Table.Row>
<Table.Cell class="font-medium">{monitor.name}</Table.Cell>
<Table.Cell class="font-medium text-{monitor.status.toLowerCase()}">{monitor.status}</Table.Cell>
<Table.Cell>{monitor.responseTime}</Table.Cell>
<Table.Cell class="text-center">{monitor.uptimes[0]?.percentage ?? "-"}%</Table.Cell>
<Table.Cell class="text-center">{monitor.uptimes[1]?.percentage ?? "-"}%</Table.Cell>
<Table.Cell class="text-center">{monitor.uptimes[2]?.percentage ?? "-"}%</Table.Cell>
<Table.Cell class="text-center">{monitor.uptimes[3]?.percentage ?? "-"}%</Table.Cell>
<Table.Cell class="text-center">
<Button variant="ghost" size="icon">
<ArrowRight />
</Button>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
+138
View File
@@ -0,0 +1,138 @@
<script lang="ts">
import ArrowUpRight from "@lucide/svelte/icons/arrow-up-right";
import { Button } from "$lib/components/ui/button/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import type { NumberWithChange } from "$lib/types/monitor.js";
interface Props {
incidentNumber: NumberWithChange;
maintenanceNumber: NumberWithChange;
uptimeNumber: NumberWithChange;
}
let { incidentNumber, maintenanceNumber, uptimeNumber }: Props = $props();
// Determine system status based on uptime
let systemStatus = $derived(
uptimeNumber.currentNumber >= 99.9
? "All Systems Operational"
: uptimeNumber.currentNumber >= 99
? "Minor Issues Detected"
: uptimeNumber.currentNumber >= 95
? "Some Systems Degraded"
: "Systems Experiencing Issues"
);
// Helper to format change display
function formatChange(
change: number,
isPercentage: boolean = false
): { text: string; isPositive: boolean; isZero: boolean } {
if (change === 0) {
return { text: "No change", isPositive: false, isZero: true };
}
const absChange = Math.abs(change);
const formatted = isPercentage ? `${absChange.toFixed(2)}%` : `${absChange}`;
return {
text: change > 0 ? `▲ ${formatted}` : `▼ ${formatted}`,
isPositive: change > 0,
isZero: false
};
}
// For incidents/maintenance, fewer is better (so negative change is good)
function formatIncidentChange(change: number): { text: string; isGood: boolean; isZero: boolean } {
if (change === 0) {
return { text: "No change", isGood: false, isZero: true };
}
const absChange = Math.abs(change);
return {
text: change < 0 ? `▼ ${absChange} from previous period` : `▲ ${absChange} from previous period`,
isGood: change < 0,
isZero: false
};
}
let uptimeChange = $derived(formatChange(uptimeNumber.changePercentage, true));
let incidentChange = $derived(formatIncidentChange(incidentNumber.change));
let maintenanceChange = $derived(formatIncidentChange(maintenanceNumber.change));
</script>
<div>
<div class="flex gap-4">
<!-- Summary Card -->
<div class="bg-primary flex w-62.5 flex-col gap-y-3 rounded-3xl border p-4">
<Badge variant="secondary">Summary</Badge>
<p class="text-secondary text-2xl">{systemStatus}</p>
</div>
<!-- Uptime Card -->
<div class="flex w-62.5 flex-col justify-start gap-y-3 rounded-3xl border p-4">
<div class="relative flex justify-between">
<Badge variant="secondary">Uptime</Badge>
<Button variant="ghost" class="absolute top-0 -right-1 -translate-y-1 cursor-pointer" size="icon">
<ArrowUpRight />
</Button>
</div>
<p class="text-2xl">{uptimeNumber.currentNumber.toFixed(2)}%</p>
<div class="flex flex-col justify-end gap-1">
<p class="text-sm">Last 90 days</p>
<p
class="text-xs {uptimeChange.isZero
? 'text-muted-foreground'
: uptimeChange.isPositive
? 'text-green-500'
: 'text-red-500'}"
>
{uptimeChange.text}
</p>
</div>
</div>
<!-- Incidents Card -->
<div class="flex w-62.5 flex-col justify-start gap-y-3 rounded-3xl border p-4">
<div class="relative flex justify-between">
<Badge variant="secondary">Incidents</Badge>
<Button variant="ghost" class="absolute top-0 -right-1 -translate-y-1 cursor-pointer" size="icon">
<ArrowUpRight />
</Button>
</div>
<p class="text-2xl">{incidentNumber.currentNumber}</p>
<div class="flex flex-col justify-end gap-1">
<p class="text-sm">Incidents last 90 days</p>
<p
class="text-xs {incidentChange.isZero
? 'text-muted-foreground'
: incidentChange.isGood
? 'text-green-500'
: 'text-red-500'}"
>
{incidentChange.text}
</p>
</div>
</div>
<!-- Maintenances Card -->
<div class="flex w-62.5 flex-col justify-start gap-y-3 rounded-3xl border p-4">
<div class="relative flex justify-between">
<Badge variant="secondary">Maintenances</Badge>
<Button variant="ghost" class="absolute top-0 -right-1 -translate-y-1 cursor-pointer" size="icon">
<ArrowUpRight />
</Button>
</div>
<p class="text-2xl">{maintenanceNumber.currentNumber}</p>
<div class="flex flex-col justify-end gap-1">
<p class="text-sm">Last 90 days</p>
<p
class="text-xs {maintenanceChange.isZero
? 'text-muted-foreground'
: maintenanceChange.isGood
? 'text-green-500'
: 'text-red-500'}"
>
{maintenanceChange.text}
</p>
</div>
</div>
</div>
</div>
+244
View File
@@ -0,0 +1,244 @@
<script lang="ts">
import { onMount } from "svelte";
import { mode } from "mode-watcher";
import type { TimestampStatusCount } from "$lib/server/types/db";
import { GetStatusSummary } from "$lib/clientTools";
import { page } from "$app/state";
interface Props {
data: TimestampStatusCount[];
barHeight?: number;
gap?: number;
radius?: number;
colorUp?: string;
colorDown?: string;
colorDegraded?: string;
colorMaintenance?: string;
}
let {
data,
barHeight = 40,
gap = 0,
radius = 8,
colorUp = page.data.siteStatusColors.UP,
colorDown = page.data.siteStatusColors.DOWN,
colorDegraded = page.data.siteStatusColors.DEGRADED,
colorMaintenance = page.data.siteStatusColors.MAINTENANCE
}: Props = $props();
let canvas: HTMLCanvasElement;
let container: HTMLDivElement;
let canvasWidth = $state(0);
let hoveredBar = $state<{ index: number; x: number; data: TimestampStatusCount } | null>(null);
let mounted = $state(false);
let dpr = $state(1);
function calculateBarWidth(): number {
if (data.length === 0) return 0;
const totalGaps = (data.length - 1) * gap;
return Math.max(1, (canvasWidth - totalGaps) / data.length);
}
function drawBars() {
if (!canvas || canvasWidth === 0 || !mounted) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Scale canvas for high-DPI displays
const scaledWidth = Math.floor(canvasWidth * dpr);
const scaledHeight = Math.floor(barHeight * dpr);
canvas.width = scaledWidth;
canvas.height = scaledHeight;
ctx.scale(dpr, dpr);
const barWidth = calculateBarWidth();
const noDataColor = mode.current === "dark" ? "#27272a" : "#e4e4e7";
const roundedGap = Math.round(gap);
// Clear canvas
ctx.clearRect(0, 0, canvasWidth, barHeight);
// Draw each bar
for (let i = 0; i < data.length; i++) {
const x = Math.round(i * (barWidth + gap));
const nextX = Math.round((i + 1) * (barWidth + gap));
const roundedBarWidth = Math.max(0, nextX - x - roundedGap);
const barItem = data[i];
const total = barItem.countOfUp + barItem.countOfDown + barItem.countOfDegraded + barItem.countOfMaintenance;
if (total === 0) {
// No data - draw gray bar
ctx.fillStyle = noDataColor;
ctx.fillRect(x, 0, roundedBarWidth, barHeight);
continue;
}
// Stacked bar: draw from bottom to top
// Order: down (bottom) -> degraded -> maintenance -> up (top)
let currentY = barHeight;
// Draw down (red) at bottom
if (barItem.countOfDown > 0) {
const downHeight = Math.round((barItem.countOfDown / total) * barHeight);
currentY -= downHeight;
ctx.fillStyle = colorDown;
ctx.fillRect(x, currentY, roundedBarWidth, downHeight);
}
// Draw degraded (yellow)
if (barItem.countOfDegraded > 0) {
const degradedHeight = Math.round((barItem.countOfDegraded / total) * barHeight);
currentY -= degradedHeight;
ctx.fillStyle = colorDegraded;
ctx.fillRect(x, currentY, roundedBarWidth, degradedHeight);
}
// Draw maintenance (blue)
if (barItem.countOfMaintenance > 0) {
const maintenanceHeight = Math.round((barItem.countOfMaintenance / total) * barHeight);
currentY -= maintenanceHeight;
ctx.fillStyle = colorMaintenance;
ctx.fillRect(x, currentY, roundedBarWidth, maintenanceHeight);
}
// Draw up (green) at top
if (barItem.countOfUp > 0) {
// Fill remaining height to avoid gaps from rounding
ctx.fillStyle = colorUp;
// Extend up to 0 to ensure full coverage due to rounding
ctx.fillRect(x, 0, roundedBarWidth, currentY);
}
}
}
function handleMouseMove(event: MouseEvent) {
if (!canvas || data.length === 0 || canvasWidth === 0) return;
const rect = canvas.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const barWidth = calculateBarWidth();
const totalBarWidth = barWidth + gap;
// Find which bar the mouse is over by checking each bar's actual rendered position
let foundIndex = -1;
for (let i = 0; i < data.length; i++) {
const barStart = Math.round(i * totalBarWidth);
const barEnd = Math.round((i + 1) * totalBarWidth) - Math.round(gap);
if (mouseX >= barStart && mouseX < barEnd) {
foundIndex = i;
break;
}
}
if (foundIndex >= 0) {
const barX = Math.round(foundIndex * totalBarWidth) + Math.round(barWidth) / 2;
hoveredBar = { index: foundIndex, x: barX, data: data[foundIndex] };
} else {
hoveredBar = null;
}
}
function handleMouseLeave() {
hoveredBar = null;
}
function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleDateString("en-US", {
day: "numeric",
month: "short",
year: "numeric",
timeZone: page.data.localTz
});
}
function getStatusColor(item: TimestampStatusCount): string {
const total = item.countOfUp + item.countOfDown + item.countOfDegraded + item.countOfMaintenance;
if (total === 0) return "text-muted-foreground";
const maintenancePercent = (item.countOfMaintenance / total) * 100;
const downPercent = (item.countOfDown / total) * 100;
if (maintenancePercent > 0) return "text-maintenance";
if (downPercent > 0) return "text-down";
if (item.countOfDegraded > 0) return "text-degraded";
return "text-up";
}
onMount(() => {
mounted = true;
dpr = window.devicePixelRatio || 1;
// Set initial width
if (container) {
canvasWidth = container.clientWidth;
}
// Observe resize
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
canvasWidth = entry.contentRect.width;
}
});
if (container) {
resizeObserver.observe(container);
}
// Initial draw after mount
requestAnimationFrame(() => {
drawBars();
});
return () => {
resizeObserver.disconnect();
};
});
// Redraw when data, width, or theme changes
$effect(() => {
// Access reactive dependencies
const _width = canvasWidth;
const _data = data;
const _mode = mode.current;
if (_width > 0 && _data.length > 0 && mounted) {
drawBars();
}
});
</script>
<div class="relative w-full" bind:this={container}>
<div class="overflow-hidden" style="border-radius: {radius}px;">
<canvas
bind:this={canvas}
style="width: {canvasWidth}px; height: {barHeight}px;"
class="cursor-pointer"
onmousemove={handleMouseMove}
onmouseleave={handleMouseLeave}
aria-label="Status bar showing uptime data"
></canvas>
</div>
{#if hoveredBar}
<div
class="bg-foreground text-secondary pointer-events-none absolute z-20 w-max -translate-x-1/2 rounded-md px-2 py-1 text-xs font-medium whitespace-nowrap"
style="left: {hoveredBar.x}px; bottom: {barHeight + 8}px;"
>
<span class={getStatusColor(hoveredBar.data)}>{GetStatusSummary(hoveredBar.data)}</span>
<span class="text-muted">@</span>
{formatTimestamp(hoveredBar.data.ts)}
</div>
{/if}
</div>
<style>
canvas {
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
image-rendering: pixelated;
}
</style>
+395
View File
@@ -0,0 +1,395 @@
<script lang="ts">
import { onMount } from "svelte";
import { mode } from "mode-watcher";
import { page } from "$app/state";
import { GetStatusSummary, ParseLatency } from "$lib/clientTools";
import MonitorDayDetail from "$lib/components/MonitorDayDetail.svelte";
import type { TimestampStatusCount } from "$lib/server/types/db";
interface Props {
data: TimestampStatusCount[];
monitorTag: string;
localTz: string;
barHeight?: number;
radius?: number;
class?: string;
disableClick?: boolean;
}
let {
data,
monitorTag,
localTz,
barHeight = 40,
radius = 8,
class: className = "",
disableClick = false
}: Props = $props();
// Canvas state
let canvas = $state<HTMLCanvasElement | null>(null);
let container = $state<HTMLDivElement | null>(null);
let tooltipEl = $state<HTMLDivElement | null>(null);
let canvasWidth = $state(0);
let hoveredBar = $state<{ index: number; x: number; data: TimestampStatusCount } | null>(null);
let mounted = $state(false);
let dpr = $state(1);
let resizeObserver: ResizeObserver | null = null;
// Calculate clamped tooltip position to prevent overflow
let tooltipStyle = $derived.by(() => {
if (!hoveredBar || !tooltipEl) {
return `left: 0px; bottom: ${barHeight + 16}px; opacity: 0;`;
}
const tooltipWidth = tooltipEl.offsetWidth;
const halfTooltip = tooltipWidth / 2;
const padding = 4; // Small padding from edges
// Clamp position so tooltip stays within container
let left = hoveredBar.x;
const minLeft = halfTooltip + padding;
const maxLeft = canvasWidth - halfTooltip - padding;
if (left < minLeft) {
left = minLeft;
} else if (left > maxLeft) {
left = maxLeft;
}
return `left: ${left}px; bottom: ${barHeight + 16}px;`;
});
// Dialog state for day detail
let dialogOpen = $state(false);
let selectedDay = $state<{ timestamp: number; status: string } | null>(null);
// Colors from page data
const colorUp = $derived(page.data.siteStatusColors?.UP || "#22c55e");
const colorDown = $derived(page.data.siteStatusColors?.DOWN || "#ef4444");
const colorDegraded = $derived(page.data.siteStatusColors?.DEGRADED || "#eab308");
const colorMaintenance = $derived(page.data.siteStatusColors?.MAINTENANCE || "#3b82f6");
const gap = 0;
function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleDateString("en-US", {
day: "numeric",
month: "short",
year: "numeric",
timeZone: localTz
});
}
// Track hovered index separately for efficient redraw
let hoveredIndex = $state<number | null>(null);
function calculateBarWidth(): number {
if (!data || data.length === 0 || canvasWidth === 0) return 0;
const totalGaps = (data.length - 1) * gap;
return Math.max(1, (canvasWidth - totalGaps) / data.length);
}
// Helper to draw a rounded rect path (only rounds specified corners)
function roundedRectPath(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
r: number,
roundLeft: boolean,
roundRight: boolean
) {
const tl = roundLeft ? r : 0;
const bl = roundLeft ? r : 0;
const tr = roundRight ? r : 0;
const br = roundRight ? r : 0;
ctx.beginPath();
ctx.moveTo(x + tl, y);
ctx.lineTo(x + width - tr, y);
if (tr) ctx.arcTo(x + width, y, x + width, y + tr, tr);
else ctx.lineTo(x + width, y);
ctx.lineTo(x + width, y + height - br);
if (br) ctx.arcTo(x + width, y + height, x + width - br, y + height, br);
else ctx.lineTo(x + width, y + height);
ctx.lineTo(x + bl, y + height);
if (bl) ctx.arcTo(x, y + height, x, y + height - bl, bl);
else ctx.lineTo(x, y + height);
ctx.lineTo(x, y + tl);
if (tl) ctx.arcTo(x, y, x + tl, y, tl);
else ctx.lineTo(x, y);
ctx.closePath();
}
function drawBars(highlightIndex: number | null = null) {
if (!canvas || canvasWidth === 0 || !mounted || !data || data.length === 0) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Add padding for scale effect
const padding = 4;
const totalHeight = barHeight + padding * 2;
// Scale canvas for high-DPI displays
const scaledWidth = Math.floor(canvasWidth * dpr);
const scaledHeight = Math.floor(totalHeight * dpr);
canvas.width = scaledWidth;
canvas.height = scaledHeight;
ctx.scale(dpr, dpr);
const barWidth = calculateBarWidth();
const noDataColor = mode.current === "dark" ? "#27272a" : "#e4e4e7";
const roundedGap = Math.round(gap);
// Clear canvas
ctx.clearRect(0, 0, canvasWidth, totalHeight);
// Draw each bar
for (let i = 0; i < data.length; i++) {
const x = Math.round(i * (barWidth + gap));
const nextX = Math.round((i + 1) * (barWidth + gap));
const roundedBarWidth = Math.max(0, nextX - x - roundedGap);
const barItem = data[i];
const total = barItem.countOfUp + barItem.countOfDown + barItem.countOfDegraded + barItem.countOfMaintenance;
// Calculate scale and opacity based on hover
let scale = 1;
let opacity = 1;
if (highlightIndex !== null) {
if (i === highlightIndex) {
scale = 1.15;
opacity = 1;
} else if (i === highlightIndex - 1 || i === highlightIndex + 1) {
scale = 1.08;
opacity = 0.9;
} else {
scale = 1;
opacity = 0.5;
}
}
ctx.globalAlpha = opacity;
// Calculate scaled dimensions
const scaledBarHeight = barHeight * scale;
const yOffset = padding + (barHeight - scaledBarHeight) / 2;
// Determine if this bar needs rounded corners
const isFirst = i === 0;
const isLast = i === data.length - 1;
const cornerRadius = radius * scale;
// Set up clipping path for rounded corners on first/last bars
ctx.save();
if (isFirst || isLast) {
roundedRectPath(ctx, x, yOffset, roundedBarWidth, scaledBarHeight, cornerRadius, isFirst, isLast);
ctx.clip();
}
if (total === 0) {
// No data - draw gray bar
ctx.fillStyle = noDataColor;
ctx.fillRect(x, yOffset, roundedBarWidth, scaledBarHeight);
ctx.restore();
continue;
}
// Stacked bar: draw from bottom to top
// Order: down (bottom) -> degraded -> maintenance -> up (top)
let currentY = yOffset + scaledBarHeight;
// Draw down (red) at bottom
if (barItem.countOfDown > 0) {
const downHeight = Math.round((barItem.countOfDown / total) * scaledBarHeight);
currentY -= downHeight;
ctx.fillStyle = colorDown;
ctx.fillRect(x, currentY, roundedBarWidth, downHeight);
}
// Draw degraded (yellow)
if (barItem.countOfDegraded > 0) {
const degradedHeight = Math.round((barItem.countOfDegraded / total) * scaledBarHeight);
currentY -= degradedHeight;
ctx.fillStyle = colorDegraded;
ctx.fillRect(x, currentY, roundedBarWidth, degradedHeight);
}
// Draw maintenance (blue)
if (barItem.countOfMaintenance > 0) {
const maintenanceHeight = Math.round((barItem.countOfMaintenance / total) * scaledBarHeight);
currentY -= maintenanceHeight;
ctx.fillStyle = colorMaintenance;
ctx.fillRect(x, currentY, roundedBarWidth, maintenanceHeight);
}
// Draw up (green) at top - fill remaining height
if (barItem.countOfUp > 0) {
ctx.fillStyle = colorUp;
ctx.fillRect(x, yOffset, roundedBarWidth, currentY - yOffset);
}
ctx.restore();
}
// Reset global alpha
ctx.globalAlpha = 1;
}
function handleMouseMove(event: MouseEvent) {
if (!canvas || !data || data.length === 0 || canvasWidth === 0) return;
const rect = canvas.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const barWidth = calculateBarWidth();
const totalBarWidth = barWidth + gap;
// Find which bar the mouse is over
let foundIndex = -1;
for (let i = 0; i < data.length; i++) {
const barStart = Math.round(i * totalBarWidth);
const barEnd = Math.round((i + 1) * totalBarWidth) - Math.round(gap);
if (mouseX >= barStart && mouseX < barEnd) {
foundIndex = i;
break;
}
}
if (foundIndex >= 0) {
const barX = Math.round(foundIndex * totalBarWidth) + Math.round(barWidth) / 2;
hoveredBar = { index: foundIndex, x: barX, data: data[foundIndex] };
// Only redraw if hovered index changed
if (hoveredIndex !== foundIndex) {
hoveredIndex = foundIndex;
drawBars(foundIndex);
}
} else {
hoveredBar = null;
if (hoveredIndex !== null) {
hoveredIndex = null;
drawBars(null);
}
}
}
function handleMouseLeave() {
hoveredBar = null;
if (hoveredIndex !== null) {
hoveredIndex = null;
drawBars(null);
}
}
function handleBarClick(event: MouseEvent) {
if (disableClick || !hoveredBar || !data) return;
const barData = data[hoveredBar.index];
const total = barData.countOfUp + barData.countOfDown + barData.countOfDegraded + barData.countOfMaintenance;
// Determine status for the day
let status = "NO_DATA";
if (total > 0) {
if (barData.countOfDown > 0) status = "DOWN";
else if (barData.countOfDegraded > 0) status = "DEGRADED";
else if (barData.countOfMaintenance > 0) status = "MAINTENANCE";
else status = "UP";
}
selectedDay = { timestamp: barData.ts, status };
dialogOpen = true;
}
function handleDialogClose() {
selectedDay = null;
}
function getStatusColor(item: TimestampStatusCount): string {
const total = item.countOfUp + item.countOfDown + item.countOfDegraded + item.countOfMaintenance;
if (total === 0) return "text-muted-foreground";
if (item.countOfMaintenance > 0) return "text-maintenance";
if (item.countOfDown > 0) return "text-down";
if (item.countOfDegraded > 0) return "text-degraded";
return "text-up";
}
onMount(() => {
mounted = true;
dpr = window.devicePixelRatio || 1;
return () => {
resizeObserver?.disconnect();
};
});
// Set up resize observer when container becomes available
$effect(() => {
if (container && !resizeObserver) {
canvasWidth = container.clientWidth;
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
canvasWidth = entry.contentRect.width;
}
});
resizeObserver.observe(container);
}
});
// Redraw when data, width, or theme changes
$effect(() => {
const _width = canvasWidth;
const _data = data;
const _mode = mode.current;
if (_width > 0 && _data && _data.length > 0 && mounted) {
drawBars(hoveredIndex);
}
});
</script>
<div class="relative w-full {className}" bind:this={container}>
<div class="overflow-hidden" style="border-radius: {radius}px;">
<canvas
bind:this={canvas}
style="width: 100%; height: {barHeight + 8}px;"
class="cursor-pointer"
onmousemove={handleMouseMove}
onmouseleave={handleMouseLeave}
onclick={handleBarClick}
aria-label="Status calendar showing {data.length}-day uptime data"
></canvas>
</div>
{#if hoveredBar}
<div
bind:this={tooltipEl}
class="bg-foreground text-secondary pointer-events-none absolute z-20 w-max -translate-x-1/2 rounded-md px-2 py-1 text-xs font-medium whitespace-nowrap"
style={tooltipStyle}
>
<span class={getStatusColor(hoveredBar.data)}>{GetStatusSummary(hoveredBar.data)}</span>
<span class="text-muted">@</span>
{formatTimestamp(hoveredBar.data.ts)}
{#if hoveredBar.data.avgLatency > 0}
<span class="text-muted ml-1">|</span>
<span class="ml-1">{ParseLatency(hoveredBar.data.avgLatency)}</span>
{/if}
</div>
{/if}
</div>
<!-- Day Detail Dialog -->
<MonitorDayDetail bind:open={dialogOpen} {monitorTag} {selectedDay} />
<style>
canvas {
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
image-rendering: pixelated;
}
</style>
+274
View File
@@ -0,0 +1,274 @@
<script lang="ts">
import { fly } from "svelte/transition";
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
import ChevronRight from "@lucide/svelte/icons/chevron-right";
import { Button } from "$lib/components/ui/button/index.js";
import type { DayWiseStatus } from "$lib/types/monitor";
let { dayWiseStatus = [] }: { dayWiseStatus: DayWiseStatus[] } = $props();
// Create a lookup map for day-wise status by date (YYYY-MM-DD)
const dayStatusMap = $derived(() => {
const map = new Map<string, { status: "up" | "degraded" | "down"; opacity: 1 | 2 | 3 | 4 }>();
if (dayWiseStatus) {
for (const day of dayWiseStatus) {
map.set(day.date, { status: day.status, opacity: day.opacity });
}
}
return map;
});
// Direction for slide animation: 1 = forward (right to left), -1 = backward (left to right)
let slideDirection = $state(1);
// Number of days in a month
function daysInMonth(month: number, year: number) {
return new Date(year, month, 0).getDate();
}
let now = new Date();
// Month name and year for display
const monthNames = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
];
// Reactive state for current month/year
let selectedMonth = $state(now.getMonth());
let selectedYear = $state(now.getFullYear());
// Navigation functions
function goToPreviousMonth() {
slideDirection = -1;
if (selectedMonth === 0) {
selectedMonth = 11;
selectedYear--;
} else {
selectedMonth--;
}
}
function goToNextMonth() {
slideDirection = 1;
if (selectedMonth === 11) {
selectedMonth = 0;
selectedYear++;
} else {
selectedMonth++;
}
}
// Status types
type Status = "up" | "degraded" | "down";
type DayStatus = {
status: Status;
opacity: 1 | 2 | 3 | 4;
};
// Get day status from real data
function getDayStatus(day: number, month: number, year: number): DayStatus {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const statusData = dayStatusMap().get(dateStr);
if (statusData) {
return statusData;
}
return { status: "up", opacity: 4 };
}
// Get CSS classes for a day based on its status
function getDayStyle(day: number | null, month: number, year: number): string {
if (day === null) return "";
const { status, opacity } = getDayStatus(day, month, year);
const darkColor = "64, 64, 64";
if (status === "up") {
return `border: 2px solid rgb(${darkColor}); background-color: transparent`;
} else {
const opacityValue = opacity * 0.25;
return `border: 2px solid rgb(${darkColor}); background-color: rgba(${darkColor}, ${opacityValue})`;
}
}
// Get text color based on background darkness
function getDayTextColor(day: number | null, month: number, year: number): string {
if (day === null) return "";
const { status, opacity } = getDayStatus(day, month, year);
if (status === "up" || opacity <= 2) {
return "color: rgb(64, 64, 64)";
} else {
return "color: white";
}
}
const dayNames = ["M", "T", "W", "T", "F", "S", "S"];
// Helper to get month data for any month/year
function getMonthData(month: number, year: number) {
const days = daysInMonth(month + 1, year);
const firstDayOfMonth = new Date(year, month, 1).getDay();
const offset = firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1;
const calendarDays: (number | null)[] = [];
for (let i = 0; i < offset; i++) {
calendarDays.push(null);
}
for (let i = 1; i <= days; i++) {
calendarDays.push(i);
}
const weeks: (number | null)[][] = [];
for (let i = 0; i < calendarDays.length; i += 7) {
weeks.push(calendarDays.slice(i, i + 7));
}
return {
name: monthNames[month],
year,
month,
weeks
};
}
// Get two months: selected and previous
let months = $derived(() => {
let prevMonth = selectedMonth - 1;
let prevYear = selectedYear;
if (prevMonth < 0) {
prevMonth = 11;
prevYear--;
}
return [getMonthData(prevMonth, prevYear), getMonthData(selectedMonth, selectedYear)];
});
</script>
<div class="inline-block overflow-hidden rounded-3xl border border-[#f6ed77] bg-[#fcf47f] font-sans">
<div class="flex flex-col p-6">
<div
class="relative"
style="width: calc(2 * (7 * 2rem + 6 * 0.5rem) + 1.5rem); height: calc(7 * 2rem + 6 * 0.5rem + 2.5rem)"
>
{#key `${selectedMonth}-${selectedYear}`}
<div
class="absolute inset-0 flex gap-6"
in:fly={{ x: slideDirection * 100, duration: 300 }}
out:fly={{ x: slideDirection * -100, duration: 300 }}
>
{#each months() as monthData, index}
<div class="flex flex-col">
<div class="flex items-center pb-2 text-center text-sm font-medium">
{#if index === 0}
<Button
variant="ghost"
class="cursor-pointer rounded-full hover:bg-[#ede065]"
size="icon"
onclick={goToPreviousMonth}
>
<ChevronLeft class="" />
</Button>
{/if}
<span class="flex-1 text-center font-semibold">
{monthData.name}'{String(monthData.year).slice(-2)}
</span>
{#if index === 1}
<Button
variant="ghost"
class="cursor-pointer rounded-full hover:bg-[#ede065]"
size="icon"
onclick={goToNextMonth}
>
<ChevronRight />
</Button>
{/if}
</div>
<div
class="inline-flex flex-wrap gap-2"
style="width: calc(7 * 2rem + 6 * 0.5rem); min-height: calc(7 * 2rem + 6 * 0.5rem)"
>
{#each dayNames as dayName}
<div class="flex h-8 w-8 items-center justify-center rounded-full text-center text-xs font-semibold">
{dayName}
</div>
{/each}
{#each monthData.weeks as week}
{#each week as day}
<div
class="group flex h-8 w-8 cursor-pointer items-center justify-center rounded-full text-center"
style={getDayStyle(day, monthData.month, monthData.year)}
>
{#if day}
<span
class="text-xs font-medium opacity-0 transition-opacity duration-200 ease-in-out group-hover:opacity-100"
style={getDayTextColor(day, monthData.month, monthData.year)}
>
{day}
</span>
{/if}
</div>
{/each}
{/each}
</div>
</div>
{/each}
</div>
{/key}
</div>
</div>
<!-- Legend Block -->
<div
class="mx-auto mb-4 flex max-w-7/8 items-center justify-between gap-6 rounded-3xl border-2 px-6 py-4"
style="border: 2px solid rgb(64, 64, 64); background-color: transparent"
>
<div class="items-left flex flex-col gap-2">
<div
class="flex h-6 w-6 items-center justify-center rounded-full"
style="border: 2px solid rgb(64, 64, 64); background-color: transparent"
></div>
<span class="text-xs font-medium">No issues</span>
</div>
<div class="items-left flex flex-col gap-2">
<div
class="flex h-6 w-6 items-center justify-center rounded-full"
style="border: 2px solid rgb(64, 64, 64); background-color: rgba(64, 64, 64, 0.25)"
></div>
<span class="text-xs font-medium">Minor</span>
</div>
<div class="items-left flex flex-col gap-2">
<div
class="flex h-6 w-6 items-center justify-center rounded-full"
style="border: 2px solid rgb(64, 64, 64); background-color: rgba(64, 64, 64, 0.5)"
></div>
<span class="text-xs font-medium">Partial</span>
</div>
<div class="items-left flex flex-col gap-2">
<div
class="flex h-6 w-6 items-center justify-center rounded-full"
style="border: 2px solid rgb(64, 64, 64); background-color: rgba(64, 64, 64, 0.75)"
></div>
<span class="text-xs font-medium">Major</span>
</div>
<div class="items-left flex flex-col gap-2">
<div
class="flex h-6 w-6 items-center justify-center rounded-full"
style="border: 2px solid rgb(64, 64, 64); background-color: rgba(64, 64, 64, 1)"
></div>
<span class="text-xs font-medium">Outage</span>
</div>
</div>
</div>
@@ -0,0 +1,338 @@
<script lang="ts">
import { Button, buttonVariants } from "$lib/components/ui/button/index.js";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import { Switch } from "$lib/components/ui/switch/index.js";
import Loader from "lucide-svelte/icons/loader";
import LogOut from "lucide-svelte/icons/log-out";
import ChevronLeft from "lucide-svelte/icons/chevron-left";
import * as Avatar from "$lib/components/ui/avatar/index.js";
import { onMount } from "svelte";
interface MonitorTagResponse {
tag: string;
name: string;
image: string | null;
description: string | null;
}
interface Props {
open: boolean;
monitor_tags?: string[];
}
type ViewType = "loading" | "login" | "verify_login" | "subscribe";
const LOCAL_STORAGE_KEY = "user_subscription";
let monitors = $state<MonitorTagResponse[]>([]);
let { open = $bindable(false), monitor_tags = [] }: Props = $props();
// View state
let view = $state<ViewType>("loading");
let apiError = $state("");
let callingAPI = $state(false);
// Login state
let loginEmail = $state("");
let verifyToken = $state("");
let verificationCode = $state("");
// Subscription state
let userEmail = $state("");
let selectedMonitors = $state<string[]>([]);
let savedMonitors = $state<string[]>([]);
let hasActiveSubscription = $state(false);
onMount(async () => {
await fetchStoredSubscription();
await fetchMonitors();
});
async function fetchMonitors() {
if (monitor_tags.length === 0) return;
try {
const response = await fetch(`/dashboard-apis/monitor-tags?tags=${monitor_tags.join(",")}`);
if (response.ok) {
monitors = await response.json();
}
} catch (e) {
console.error("Failed to fetch monitors:", e);
}
}
async function callAPI(action: string, data: Record<string, unknown>) {
const response = await fetch(`/dashboard-apis/subscription`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, ...data })
});
if (response.ok) {
return await response.json();
} else {
const error = await response.json();
throw new Error(error.message || "An error occurred while processing your request.");
}
}
async function fetchStoredSubscription() {
view = "loading";
const token = localStorage.getItem(LOCAL_STORAGE_KEY);
if (!token) {
view = "login";
return;
}
try {
const response = await callAPI("fetch", { token });
userEmail = response.email;
view = "subscribe";
let monitors = response.monitors || [];
if (monitors.length > 0) {
hasActiveSubscription = true;
// Filter out the "_" (all monitors) marker if present
monitors = monitors.filter((m: string) => m !== "_");
} else {
monitors = [];
hasActiveSubscription = false;
}
savedMonitors = [...monitors];
selectedMonitors = [...monitors];
} catch (e) {
view = "login";
}
}
async function doLogin() {
callingAPI = true;
apiError = "";
try {
const response = await callAPI("login", { userEmail: loginEmail });
if (response) {
verifyToken = response.token;
verificationCode = "";
view = "verify_login";
}
} catch (e) {
apiError = (e as Error).message;
} finally {
callingAPI = false;
}
}
async function verifyLogin() {
callingAPI = true;
apiError = "";
try {
const response = await callAPI("verify", {
token: verifyToken,
code: verificationCode
});
if (response) {
localStorage.setItem(LOCAL_STORAGE_KEY, response.token);
await fetchStoredSubscription();
}
} catch (e) {
apiError = (e as Error).message;
} finally {
callingAPI = false;
}
}
async function saveSubscription() {
callingAPI = true;
apiError = "";
try {
const token = localStorage.getItem(LOCAL_STORAGE_KEY);
await callAPI("subscribe", {
token,
monitors: selectedMonitors
});
await fetchStoredSubscription();
open = false;
} catch (e) {
apiError = (e as Error).message;
} finally {
callingAPI = false;
}
}
async function unsubscribe() {
if (!confirm("Are you sure you want to unsubscribe?")) return;
callingAPI = true;
apiError = "";
try {
const token = localStorage.getItem(LOCAL_STORAGE_KEY);
await callAPI("unsubscribe", { token });
await fetchStoredSubscription();
} catch (e) {
apiError = (e as Error).message;
} finally {
callingAPI = false;
}
}
function doLogout() {
localStorage.removeItem(LOCAL_STORAGE_KEY);
loginEmail = "";
verificationCode = "";
verifyToken = "";
userEmail = "";
selectedMonitors = [];
hasActiveSubscription = false;
view = "login";
}
function toggleMonitor(tag: string) {
if (selectedMonitors.includes(tag)) {
selectedMonitors = selectedMonitors.filter((t) => t !== tag);
} else {
selectedMonitors = [...selectedMonitors, tag];
}
}
</script>
<Dialog.Root
{open}
onOpenChange={(o) => {
if (!o) {
// Reset to saved state when closing without saving
selectedMonitors = [...savedMonitors];
}
open = o;
}}
>
<Dialog.Overlay class="backdrop-blur-[2px]" />
<Dialog.Content class="rounded-3xl sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Manage Subscription</Dialog.Title>
<Dialog.Description>
{#if view === "login"}
Enter your email to log in. If you are not subscribed, you will be prompted to subscribe.
{:else if view === "verify_login"}
We have sent a code to your email. Please enter it below to confirm your login.
{:else if view === "subscribe"}
Select the monitors you want to receive updates for.
{:else}
Loading...
{/if}
</Dialog.Description>
</Dialog.Header>
<div class="flex flex-col gap-4">
{#if view === "loading"}
<div class="flex h-20 items-center justify-center">
<Spinner />
</div>
{/if}
{#if view === "login"}
<div class="flex flex-col gap-2">
<Input
type="email"
placeholder="Enter your email"
bind:value={loginEmail}
onkeydown={(e) => e.key === "Enter" && loginEmail && doLogin()}
/>
</div>
{#if apiError}
<p class="text-destructive text-sm">{apiError}</p>
{/if}
{/if}
{#if view === "verify_login"}
<div class="flex flex-col gap-2">
<div>
<Button variant="outline" class="rounded-btn " size="sm" onclick={() => (view = "login")}>
<ChevronLeft class="h-4 w-4" />
Change Email
</Button>
</div>
<Input
type="text"
placeholder="Enter the code"
bind:value={verificationCode}
onkeydown={(e) => e.key === "Enter" && verificationCode && verifyLogin()}
/>
</div>
{#if apiError}
<p class="text-destructive text-sm">{apiError}</p>
{/if}
{/if}
{#if view === "subscribe"}
{#if userEmail}
<div class="text-muted-foreground flex items-center justify-between text-sm">
<span>Logged in as <strong>{userEmail}</strong></span>
<Button variant="secondary" size="icon-sm" class="rounded-btn h-8 w-8" onclick={doLogout}>
<LogOut class="" />
</Button>
</div>
{/if}
{#if monitors.length > 0}
<div class="text-muted-foreground text-sm">Select the monitors you want to receive updates from.</div>
<div class="flex max-h-64 flex-col gap-2 overflow-y-auto">
{#each monitors as monitor}
<div class="flex items-center justify-between gap-2 border-b pb-2 last:border-0">
<label for={monitor.tag} class="flex cursor-pointer items-center gap-2 text-sm font-medium">
<Avatar.Root class="size-4">
<Avatar.Image src={monitor.image} />
<Avatar.Fallback>
{monitor.name.charAt(0).toUpperCase()}
</Avatar.Fallback>
</Avatar.Root>
{monitor.name}
</label>
<Switch
id={monitor.tag}
checked={selectedMonitors.includes(monitor.tag)}
onCheckedChange={() => toggleMonitor(monitor.tag)}
/>
</div>
{/each}
</div>
{/if}
{#if apiError}
<p class="text-destructive text-sm">{apiError}</p>
{/if}
{/if}
</div>
<Dialog.Footer class="flex-col gap-2 sm:flex-row">
{#if view === "login"}
<div class="flex w-full justify-end">
<Button disabled={callingAPI || !loginEmail} class="rounded-btn" onclick={doLogin}>
Login
{#if callingAPI}
<Loader class="h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
{/if}
{#if view === "verify_login"}
<Button disabled={callingAPI || !verificationCode} class="rounded-btn" onclick={verifyLogin}>
Confirm Login
{#if callingAPI}
<Loader class="h-4 w-4 animate-spin" />
{/if}
</Button>
{/if}
{#if view === "subscribe"}
<Button disabled={callingAPI} class="rounded-btn" onclick={saveSubscription}>
{hasActiveSubscription ? "Update Preferences" : "Save Preferences"}
{#if callingAPI}
<Loader class="h-4 w-4 animate-spin" />
{/if}
</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
import type { HourlyUptime } from "$lib/types/monitor.js";
interface Props {
status?: "UP" | "DEGRADED" | "DOWN";
message?: string;
hourlyUptime?: HourlyUptime[];
}
let { status = "UP", message = "All systems operational", hourlyUptime = [] }: Props = $props();
const statusConfig = $derived.by(() => {
switch (status) {
case "UP":
return { bgClass: "bg-up", textClass: "text-up" };
case "DEGRADED":
return { bgClass: "bg-degraded", textClass: "text-degraded" };
case "DOWN":
return { bgClass: "bg-down", textClass: "text-down" };
default:
return { bgClass: "bg-up", textClass: "text-up" };
}
});
</script>
<div class="flex flex-col justify-start gap-y-3 rounded-3xl p-4">
<span class="relative flex size-4">
<span class="{statusConfig.bgClass} absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
<span class="{statusConfig.bgClass} relative inline-flex size-4 rounded-full"></span>
</span>
<p class="text-secondary text-2xl">{message}</p>
<!-- Sparkbar placeholder -->
<div class="h-8 w-full"></div>
</div>
+183
View File
@@ -0,0 +1,183 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button/index.js";
import * as ButtonGroup from "$lib/components/ui/button-group/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import { setMode, mode } from "mode-watcher";
import { page } from "$app/state";
import { resolve } from "$app/paths";
import Sun from "@lucide/svelte/icons/sun";
import Moon from "@lucide/svelte/icons/moon";
import Share from "@lucide/svelte/icons/share-2";
import Code from "@lucide/svelte/icons/code";
import Sticker from "@lucide/svelte/icons/sticker";
import Languages from "lucide-svelte/icons/languages";
import Globe from "lucide-svelte/icons/globe";
import ChevronDown from "@lucide/svelte/icons/chevron-down";
import type { PageNavItem } from "$lib/server/controllers/dashboardController.js";
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
import ICONS from "$lib/icons";
import { format } from "date-fns";
import SubscribeMonitorMenu from "$lib/components/SubscribeMonitorMenu.svelte";
import CopyButton from "$lib/components/CopyButton.svelte";
import BadgesMenu from "$lib/components/BadgesMenu.svelte";
import EmbedMenu from "$lib/components/EmbedMenu.svelte";
import { onMount } from "svelte";
interface Props {
currentPath?: string;
showPagesDropdown?: boolean;
showEventsButton?: boolean;
showHomeButton?: boolean;
monitor_tags?: string[];
shareLinkString?: string;
embedMonitorTag?: string;
}
let openSubscribeMenu = $state(false);
let openBadgesMenu = $state(false);
let openEmbedMenu = $state(false);
let {
currentPath = "/",
showPagesDropdown = false,
showEventsButton = false,
showHomeButton = false,
monitor_tags = [],
shareLinkString = "",
embedMonitorTag = ""
}: Props = $props();
let protocol = $state("");
let domain = $state("");
const pages = $derived<PageNavItem[]>(page.data.allPages || []);
const currentPage = $derived(pages.find((p) => p.page_path === currentPath) || pages[0]);
const eventsPath = $derived(`/events/${format(new Date(), "MMMM-yyyy")}`);
const shareLink = $derived(
protocol && domain && shareLinkString ? `${protocol}://${domain}${resolve("/")}monitors/${shareLinkString}` : ""
);
function toggleMode() {
if (mode.current === "light") {
setMode("dark");
} else {
setMode("light");
}
}
onMount(() => {
protocol = window.location.protocol;
domain = window.location.host;
});
</script>
<div class="flex w-full items-center justify-between gap-2">
<div class="flex items-center gap-2">
{#if showPagesDropdown && pages.length > 0}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" size="sm" class="rounded-full text-xs shadow-none">
{currentPage?.page_title || "Home"}
<ChevronDown class="ml-1 h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="flex flex-col gap-1 rounded-3xl p-2">
{#each pages as page}
<Button
variant={page.page_path === currentPath ? "outline" : "ghost"}
size="sm"
href={page.page_path}
class="w-full justify-start rounded-full text-xs shadow-none"
>
{page.page_title}
</Button>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
{#if showHomeButton}
<Button href="/" variant="outline" size="sm" class="rounded-full text-xs shadow-none">
<ChevronLeft class="h-4 w-4" /> Back
</Button>
{/if}
{#if showEventsButton}
<Button href={eventsPath} variant="outline" size="sm" class="rounded-full text-xs shadow-none">
<ICONS.Events class="h-4 w-4" /> Events
</Button>
{/if}
</div>
<div class="flex gap-2">
<ButtonGroup.Root class="">
{#if monitor_tags.length > 0 && page.data.isEmailSubscriptionEnabled}
<ButtonGroup.Root class="hidden sm:flex">
<Button
variant="outline"
size="sm"
class="rounded-btn text-xs"
aria-label="Go Back"
onclick={() => (openSubscribeMenu = true)}
>
<ICONS.Bell class="" /> Subscribe
</Button>
</ButtonGroup.Root>
{/if}
<ButtonGroup.Root>
{#if !!shareLink}
<CopyButton variant="outline" text={shareLink} class="cursor-pointer rounded-full shadow-none" size="icon-sm">
<Share />
</CopyButton>
{/if}
{#if !!embedMonitorTag}
<!-- BadgeMenu -->
<Button
variant="outline"
class="relative cursor-pointer rounded-full shadow-none"
size="icon-sm"
onclick={() => (openBadgesMenu = true)}
>
<Sticker />
</Button>
<!-- Embed Menu -->
<Button
variant="outline"
class="relative cursor-pointer rounded-full shadow-none"
size="icon-sm"
onclick={() => (openEmbedMenu = true)}
>
<Code />
</Button>
{/if}
</ButtonGroup.Root>
<ButtonGroup.Root class=" hidden sm:flex">
<Button
variant="outline"
size="sm"
onclick={toggleMode}
aria-label="toggle theme mode "
class="relative rounded-full shadow-none"
>
<Sun class="absolute left-2 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon class="absolute left-2 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<!-- Show light / dark text -->
<span class="pl-5 text-xs">
{mode.current === "light" ? "Light" : "Dark"}
</span>
</Button>
<Button variant="outline" size="icon-sm" onclick={toggleMode} class="shadow-none" aria-label="">
<Globe class="" />
</Button>
<Button variant="outline" size="icon-sm" onclick={toggleMode} aria-label="" class="rounded-full shadow-none">
<Languages class="" />
</Button>
</ButtonGroup.Root>
<ButtonGroup.Root class="hidden sm:flex"></ButtonGroup.Root>
</ButtonGroup.Root>
</div>
</div>
<SubscribeMonitorMenu bind:open={openSubscribeMenu} {monitor_tags} />
<BadgesMenu bind:open={openBadgesMenu} monitorTag={embedMonitorTag} {protocol} {domain} />
<EmbedMenu bind:open={openEmbedMenu} monitorTag={embedMonitorTag} {protocol} {domain} />
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import ArrowUpRight from "@lucide/svelte/icons/arrow-up-right";
import ArrowUp from "@lucide/svelte/icons/arrow-up";
import ArrowDown from "@lucide/svelte/icons/arrow-down";
import Minus from "@lucide/svelte/icons/minus";
import { Badge } from "$lib/components/ui/badge/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import type { NumberWithChange } from "$lib/types/monitor";
interface Props {
summary: NumberWithChange;
}
let { summary }: Props = $props();
// Compute change info (direction, color, and formatted value)
let changeInfo = $derived.by(() => {
const { change } = summary;
if (change === 0) return { direction: "none", colorClass: "text-gray-500", formattedChange: "0" };
const absChange = Math.abs(change);
// Format with appropriate precision
const formattedChange = absChange < 0.01 ? absChange.toFixed(4) : absChange.toFixed(2);
if (change > 0) return { direction: "up", colorClass: "text-up", formattedChange };
return { direction: "down", colorClass: "text-down", formattedChange };
});
</script>
<div class="bg-secondary flex h-full flex-col justify-start gap-y-3 rounded-3xl border p-4">
<div class="flex flex-col gap-2">
<div class="relative flex justify-between">
<Badge>Uptime</Badge>
<Button variant="ghost" class="absolute -top-2 -right-2 cursor-pointer " size="icon">
<ArrowUpRight />
</Button>
</div>
<div class="flex flex-col items-start gap-2">
<p class="text-2xl">{summary.currentNumber}%</p>
<p class="flex items-center gap-0.5 {changeInfo.colorClass}">
{#if changeInfo.direction === "up"}
<ArrowUp class="size-4" />
{:else if changeInfo.direction === "down"}
<ArrowDown class="size-4" />
{:else}
<Minus class="size-4" />
{/if}
<span class="text-sm">{changeInfo.formattedChange}%</span>
</p>
</div>
</div>
</div>
+11
View File
@@ -0,0 +1,11 @@
<script>
import { base } from "$app/paths";
export let src = "";
export let alt = "GMI";
export let classList = "";
export let style = "";
export let srcset = "";
if (!src.startsWith("http")) src = `${base}${src}`;
</script>
<img {src} {alt} class={classList} {style} {srcset} />
+8
View File
@@ -0,0 +1,8 @@
<script lang="ts">
const boxes = Array.from({ length: 24 * 60 }, (_, i) => i);
</script>
<!-- Loop 1440 times (24 hours * 60 minutes) -->
{#each boxes as k (k)}
<div class="today-sq animatebg m-px h-2.5 w-2.5" style="animation-delay:{Math.random() * (k * 15)}ms;"></div>
{/each}
+434
View File
@@ -0,0 +1,434 @@
<script>
import * as Popover from "$lib/components/ui/popover";
import { onMount } from "svelte";
import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { base } from "$app/paths";
import { sub, startOfDay, getUnixTime } from "date-fns";
import GMI from "$lib/components/gmi.svelte";
import { page } from "$app/state";
import { analyticsEvent } from "$lib/boringOne";
import Share2 from "lucide-svelte/icons/share-2";
import ArrowRight from "lucide-svelte/icons/arrow-right";
import Settings from "lucide-svelte/icons/settings";
import TrendingUp from "lucide-svelte/icons/trending-up";
import Loader from "lucide-svelte/icons/loader";
import ChevronLeft from "lucide-svelte/icons/chevron-left";
import ChevronRight from "lucide-svelte/icons/chevron-right";
import { buttonVariants } from "$lib/components/ui/button";
import { createEventDispatcher } from "svelte";
import { afterUpdate } from "svelte";
import axios from "axios";
import { l, summaryTime, f } from "$lib/i18n/client";
import { hoverAction, clickOutsideAction, slide } from "@rajnandan1/svelte-legos";
import LoaderBoxes from "$lib/components/loaderbox.svelte";
import NumberFlow from "@number-flow/svelte";
import Incident from "$lib/components/IncidentNew.svelte";
const dispatch = createEventDispatcher();
export let monitor;
export let localTz;
export let lang;
export let embed = false;
export let selectedLang = "en";
let _0Day = {};
let _90Day = monitor.pageData._90Day;
let uptime90Day = monitor.pageData.uptime90Day;
let incidents = {};
let dayIncidentsFull = [];
let homeDataMaxDays = monitor.pageData.homeDataMaxDays;
let dimension = {
x1: 6,
x2: 4
};
dimension.x1 = ($page.data.isMobile ? 346 : 546) / homeDataMaxDays.maxDays;
dimension.x2 = (4 / 6) * dimension.x1;
function loadIncidents() {
axios
.post(`${base}/api/today/incidents`, {
tag: monitor.tag,
startTs: monitor.pageData.midnight90DaysAgo,
endTs: monitor.pageData.maxDateTodayTimestamp,
localTz: localTz
})
.then((response) => {
if (response.data) {
incidents = response.data;
}
})
.catch((error) => {
console.log(error);
});
}
function getToday(startTs, incidentIDs) {
let endTs = Math.min(startTs + 86400, monitor.pageData.maxDateTodayTimestamp);
axios
.post(`${base}/api/today`, {
monitor: monitor,
localTz: localTz,
endTs: endTs,
startTs: startTs,
incidentIDs: incidentIDs
})
.then((response) => {
if (response.data) {
_0Day = response.data._0Day;
dayUptime = response.data.uptime;
dayIncidentsFull = response.data.incidents;
}
loadingDayData = false;
})
.catch((error) => {
console.log(error);
loadingDayData = false;
});
}
function scrollToRight() {
setTimeout(() => {
let divs = document.querySelectorAll(".daygrid90");
divs.forEach((div) => {
div.scrollLeft = div.scrollWidth;
});
}, 1000 * 0.2);
}
function returnUptimeRollers() {
let rollers = homeDataMaxDays.selectableDays;
//sort descending
rollers.sort((a, b) => b - a);
let ret = [];
for (let i = 0; i < rollers.length; i++) {
let roller = rollers[i];
if (roller == 1) {
ret.push({
text: `${l(lang, "Today")}`,
startTs: monitor.pageData.startOfTheDay,
endTs: monitor.pageData.maxDateTodayTimestamp
});
} else {
ret.push({
text: `${roller} ${l(lang, "Days")}`,
startTs: monitor.pageData.startOfTheDay - 86400 * (roller - 1),
endTs: monitor.pageData.maxDateTodayTimestamp
});
}
//if last index
if (i == 0) {
ret[i].value = uptime90Day;
}
}
return ret;
}
let uptimesRollers = returnUptimeRollers();
//start of the week moment
let rolledAt = 0;
let rollerLoading = false;
async function rollSummary(r) {
let newRolledAt = r;
analyticsEvent("monitor_interval_switch", {
tag: monitor.tag,
interval: uptimesRollers[newRolledAt].text
});
if (uptimesRollers[newRolledAt].value === undefined) {
rollerLoading = true;
uptimesRollers[newRolledAt].loading = true;
let resp = await axios.post(`${base}/api/today/aggregated`, {
monitor: {
tag: monitor.tag,
include_degraded_in_downtime: monitor.include_degraded_in_downtime
},
startTs: uptimesRollers[newRolledAt].startTs,
endTs: uptimesRollers[newRolledAt].endTs
});
uptimesRollers[newRolledAt].value = resp.data.uptime;
rollerLoading = false;
}
rolledAt = newRolledAt;
for (const key in _90Day) {
if (Object.prototype.hasOwnProperty.call(_90Day, key)) {
const element = _90Day[key];
if (key >= uptimesRollers[rolledAt].startTs) {
_90Day[key].border = true;
} else {
_90Day[key].border = false;
}
}
}
}
onMount(async () => {
scrollToRight();
loadIncidents();
});
afterUpdate(() => {
dispatch("heightChange", {});
});
function show90Inline(e, bar) {
if (e.detail.hover) {
_90Day[bar.timestamp].showDetails = true;
} else {
_90Day[bar.timestamp].showDetails = false;
}
}
let showDailyDataModal = false;
let dateFetchedFor = "";
let dayUptime = "NA";
let loadingDayData = false;
function dailyDataGetter(e, bar, incidentObj) {
if (embed) {
return;
}
let incidentIDs = incidentObj?.ids || [];
dayUptime = "NA";
dateFetchedFor = f(new Date(bar.timestamp * 1000), "EEEE, MMMM do, yyyy", selectedLang, $page.data.localTz);
showDailyDataModal = true;
loadingDayData = true;
dayIncidentsFull = [];
analyticsEvent("monitor_day_data", {
tag: monitor.tag,
data_date: dateFetchedFor
});
setTimeout(() => {
getToday(bar.timestamp, incidentIDs);
}, 50);
}
</script>
<div class="monitor relative grid w-full max-w-[655px] grid-cols-12 gap-2 pt-0 pb-2">
{#if !!!embed}
<div class="col-span-12 md:w-[546px]">
<div class="pt-0">
<div class="scroll-m-20 pr-5 text-xl font-medium tracking-tight">
{#if monitor.image}
<GMI
src={monitor.image}
classList="absolute left-6 top-6 inline h-5 w-5 hidden md:block"
alt={monitor.name}
srcset=""
/>
{/if}
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
{monitor.name}
</p>
<p class="text-muted-foreground mt-1 text-xs font-medium">
{#if !!monitor.description}
{@html monitor.description}
{/if}
</p>
<div class="absolute top-5 right-4 flex gap-x-2 md:right-14">
{#if $page.data.isLoggedIn}
<Button
href="{base}/manage/app/monitors#{monitor.tag}"
class=" rotate-once text-muted-foreground hover:text-primary h-5 p-0"
variant="link"
rel="external"
>
<Settings class="h-4 w-4 " />
</Button>
{/if}
<Button
class="wiggle text-muted-foreground hover:text-primary h-5 p-0"
variant="link"
on:click={(e) => {
dispatch("show_shareMenu", {
monitor: monitor
});
}}
>
<Share2 class="h-4 w-4 " />
</Button>
</div>
</div>
</div>
</div>
{/if}
<div class="col-span-12 min-h-[94px] pt-2 md:w-[546px]">
<div class="col-span-12">
<div class="flex flex-wrap justify-between gap-x-1">
<div class="">
<DropdownMenu.Root class="">
<DropdownMenu.Trigger class="mr-2 flex ">
<Button variant="secondary" class="h-6 px-2 py-2 text-xs">
{uptimesRollers[rolledAt].text}
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content class={!!embed ? "h-[60px] overflow-y-auto" : ""}>
{#each uptimesRollers as roller, i}
<DropdownMenu.Group>
<DropdownMenu.Item
class="text-xs {rolledAt == i ? 'bg-secondary' : ''} font-semibold"
on:click={() => rollSummary(i)}
>
{roller.text}
</DropdownMenu.Item>
</DropdownMenu.Group>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
<div class="flex gap-x-2 pt-2 text-right">
{#if rollerLoading}
<Loader class=" text-muted-foreground mt-0.5 inline h-3.5 w-3.5 animate-spin" />
{/if}
{#if !isNaN(uptimesRollers[rolledAt].value)}
<NumberFlow
class="border-r pr-2 text-xs font-semibold"
value={uptimesRollers[rolledAt].value}
format={{
notation: "standard",
minimumFractionDigits: 4,
maximumFractionDigits: 4
}}
suffix="%"
/>
{/if}
<div class="truncate text-xs font-semibold text-{monitor.pageData.summaryColorClass}">
{monitor.pageData.summaryStatus}
</div>
</div>
</div>
<div
class="relative col-span-12 mt-0.5"
use:clickOutsideAction
on:clickoutside={(e) => {
showDailyDataModal = false;
}}
>
<div class="daygrid90 flex min-h-[60px] justify-between overflow-x-auto overflow-y-hidden py-1">
{#each Object.entries(_90Day) as [ts, bar]}
<button
data-ts={ts}
use:hoverAction
on:hover={(e) => {
show90Inline(e, bar);
}}
on:click={(e) => {
dailyDataGetter(e, bar, incidents[ts]);
}}
class="oneline h-[34px]
{bar.border ? 'opacity-100' : 'opacity-20'} pb-1"
style="width: {dimension.x1}px"
>
<div
class="oneline-in h-[30px] bg-{bar.cssClass} mx-auto rounded-{monitor.pageData.barRoundness.toUpperCase() ==
'SHARP'
? 'none'
: 'sm'}"
style="width: {dimension.x2}px"
></div>
<!-- incident dot -->
{#if !!incidents[ts]}
<div
class="bg-api-{incidents[
ts
].monitor_impact.toLowerCase()} comein absolute -bottom-[3px] h-[4px] w-[4px] rounded-full"
style="left: {dimension.x1 / 2 - 2}px"
></div>
{/if}
</button>
{#if bar.showDetails}
<div class="show-hover absolute text-sm">
<div class="text-{bar.textClass} text-xs font-semibold">
{f(new Date(bar.timestamp * 1000), "EEEE, MMMM do, yyyy", selectedLang, $page.data.localTz)}
-
{bar.summaryStatus}
</div>
</div>
{/if}
{/each}
</div>
{#if monitor.monitor_type === "GROUP" && !!!embed}
<div class="-mt-4 flex justify-end">
<Button
variant="secondary"
href="{base}?group={monitor.tag}"
rel="external"
class="bounce-right h-8 text-xs"
on:click={scrollToRight}
>
{l(lang, "View in detail")}
<ArrowRight class="arrow ml-1.5 h-4 w-4" />
</Button>
</div>
{/if}
{#if showDailyDataModal}
<div
transition:slide={{ direction: "bottom" }}
class="bg-card absolute top-10 -left-2 z-10 mx-auto rounded-sm border px-[7px] py-[7px] shadow-lg md:w-[560px]"
>
<div class="mb-2 flex justify-between text-xs font-semibold">
<span>{dateFetchedFor}</span>
{#if !loadingDayData}
<span>
<TrendingUp class="mx-1 inline" size={12} />
{dayUptime}%</span
>
{/if}
</div>
{#if dayIncidentsFull.length > 0}
<div class="-mx-2 mb-4 grid grid-cols-1">
<div class="col-span-1 px-2">
<Badge variant="outline" class="border-0 pl-0">
{l(lang, "Incident Updates")}
</Badge>
</div>
{#each dayIncidentsFull as incident, index}
<div class="col-span-1">
<Incident {incident} {lang} index="incident-{index}" />
</div>
{/each}
</div>
{/if}
<div class="flex flex-wrap">
{#if loadingDayData}
<LoaderBoxes />
{:else}
{#each Object.entries(_0Day) as [ts, bar]}
<div data-index={bar.index} class="bg-{bar.cssClass} today-sq m-[1px] h-[10px] w-[10px]"></div>
<div class="hiddenx relative">
<div
data-index={ts.index}
class="message rounded border bg-black p-2 text-xs font-semibold text-white"
>
<p>
<span class="text-{bar.cssClass}"></span>
{f(new Date(bar.timestamp * 1000), "hh:mm a", selectedLang, $page.data.localTz)}
</p>
{#if bar.status != "NO_DATA"}
<p class="pl-2">
{l(lang, bar.status)}
</p>
{:else}
<p class="pl-2">-</p>
{/if}
</div>
</div>
{/each}
{/if}
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
+119
View File
@@ -0,0 +1,119 @@
<script>
import { Button } from "$lib/components/ui/button";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import Languages from "lucide-svelte/icons/languages";
import Menu from "lucide-svelte/icons/menu";
import { base } from "$app/paths";
import { analyticsEvent } from "$lib/boringOne";
import GMI from "$lib/components/gmi.svelte";
export let data;
let defaultPattern = data.site?.pattern || "squares";
let allPets = [
{
url: base + "/chicken.gif",
bottom: "-5"
},
{
url: base + "/dog.gif",
bottom: "-17"
},
{
url: base + "/cockatiel.gif",
bottom: "-10"
},
{
url: base + "/crab.gif",
bottom: "-20"
},
{
url: base + "/fox.gif",
bottom: "-9"
},
{
url: base + "/horse.gif",
bottom: "-11"
},
{
url: base + "/panda.gif",
bottom: "0"
},
{
url: base + "/totoro.gif",
bottom: "-27"
},
{
url: base + "/rabbit.gif",
bottom: "0"
},
{
url: base + "/duck.gif",
bottom: "-5"
},
{
url: base + "/snake.gif",
bottom: "0"
}
];
let randomPet = allPets[Math.floor(Math.random() * allPets.length)];
</script>
{#if defaultPattern == "pets" && !!randomPet}
<div class="pets-pattern" style="background-image: url({randomPet.url});bottom: {randomPet.bottom}px"></div>
{:else}
<div class="{defaultPattern}-pattern"></div>
{/if}
<header class="sticky top-0 z-50 mx-auto md:mt-2">
<div class="container flex h-14 max-w-[820px] items-center border bg-card px-3 md:rounded-md">
<a rel="external" href={data.site.home ? data.site.home : base} class="mr-6 flex items-center space-x-2">
{#if data.site.logo}
<GMI src={data.site.logo} classList="w-8" alt={data.site.title} srcset="" />
{/if}
{#if data.site.siteName}
<span class=" inline-block text-[15px] font-bold lg:text-base">
{data.site.siteName}
</span>
{/if}
</a>
<div class="flex w-full justify-end">
{#if data.site.nav}
<nav class=" hidden flex-wrap items-center text-sm font-medium md:flex">
{#each data.site.nav as navItem}
<a
rel="external"
href={navItem.url}
class="flex rounded-md px-3 py-2 text-card-foreground transition-all ease-linear hover:bg-background"
on:click={() =>
analyticsEvent("navigation", {
name: navItem.name
})}
>
{#if navItem.iconURL}
<GMI src={navItem.iconURL} classList="mr-1.5 mt-0.5 inline h-4" alt={navItem.name} />
{/if}
<span>{navItem.name}</span>
</a>
{/each}
</nav>
<div class="flex md:hidden">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="outline" size="sm">
<Menu size={14} />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{#each data.site.nav as navItem}
<DropdownMenu.Group>
<DropdownMenu.Item>
<a rel="external" href={navItem.url}> {navItem.name} </a>
</DropdownMenu.Item>
</DropdownMenu.Group>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{/if}
</div>
</div>
</header>
+295
View File
@@ -0,0 +1,295 @@
<script>
import Share2 from "lucide-svelte/icons/share-2";
import Link from "lucide-svelte/icons/link";
import CopyCheck from "lucide-svelte/icons/copy-check";
import Code from "lucide-svelte/icons/code";
import TrendingUp from "lucide-svelte/icons/trending-up";
import Percent from "lucide-svelte/icons/percent";
import Loader from "lucide-svelte/icons/loader";
import ExternalLink from "lucide-svelte/icons/external-link";
import { Button } from "$lib/components/ui/button";
import { base } from "$app/paths";
import { onMount } from "svelte";
import { analyticsEvent } from "$lib/boringOne";
import { l, summaryTime } from "$lib/i18n/client";
import * as RadioGroup from "$lib/components/ui/radio-group";
import { Label } from "$lib/components/ui/label";
import GMI from "$lib/components/gmi.svelte";
export let monitor;
export let lang;
let theme = "light";
let copiedLink = false;
let embedType = "js";
let copiedEmbed = false;
let copiedBadgeStatus = false;
let copiedBadgeUptime = false;
let copiedBadgeDotStandard = false;
let copiedBadgeDotPing = false;
let protocol;
let domain;
let pathMonitorLink;
function copyLinkToClipboard() {
analyticsEvent("monitor_link_copied", {
tag: monitor.tag
});
navigator.clipboard.writeText(pathMonitorLink);
copiedLink = true;
setTimeout(function () {
copiedLink = false;
}, 1500);
}
let pathMonitorBadgeUptime;
function copyUptimeBadge() {
analyticsEvent("monitor_uptime_badge_copied", {
tag: monitor.tag
});
navigator.clipboard.writeText(pathMonitorBadgeUptime);
copiedBadgeUptime = true;
setTimeout(function () {
copiedBadgeUptime = false;
}, 1500);
}
let pathMonitorBadgeStatus;
function copyStatusBadge() {
analyticsEvent("monitor_status_badge_copied", {
tag: monitor.tag
});
navigator.clipboard.writeText(pathMonitorBadgeStatus);
copiedBadgeStatus = true;
setTimeout(function () {
copiedBadgeStatus = false;
}, 1500);
}
let pathMonitorBadgeDot;
function copyDotStandard() {
analyticsEvent("monitor_svg_standard_copied", {
tag: monitor.tag
});
navigator.clipboard.writeText(pathMonitorBadgeDot);
copiedBadgeDotStandard = true;
setTimeout(function () {
copiedBadgeDotStandard = false;
}, 1500);
}
let pathMonitorBadgeDotPing;
function copyDotPing() {
analyticsEvent("monitor_svg_pinging_copied", {
tag: monitor.tag
});
navigator.clipboard.writeText(pathMonitorBadgeDotPing);
copiedBadgeDotPing = true;
setTimeout(function () {
copiedBadgeDotPing = false;
}, 1500);
}
function copyScriptTagToClipboard() {
//get domain with port number
analyticsEvent("monitor_embed_copied", {
tag: monitor.tag,
type: embedType
});
let path = `${base}/embed/monitor-${monitor.tag}`;
let scriptTag =
`<script async src="${protocol + "//" + domain + path}/js?theme=${theme}&monitor=${protocol + "//" + domain + path}"><` +
"/script>";
if (embedType == "iframe") {
scriptTag = `<iframe src="${protocol + "//" + domain + path}?theme=${theme}" width="100%" height="200" allowfullscreen="allowfullscreen" allowpaymentrequest frameborder="0"></iframe>`;
}
navigator.clipboard.writeText(scriptTag);
copiedEmbed = true;
setTimeout(function () {
copiedEmbed = false;
}, 1500);
}
onMount(async () => {
protocol = window.location.protocol;
domain = window.location.host;
pathMonitorLink = `${protocol}//${domain}${base}/?monitor=${monitor.tag}`;
pathMonitorBadgeUptime = `${protocol}//${domain}${base}/badge/${monitor.tag}/uptime`;
pathMonitorBadgeStatus = `${protocol}//${domain}${base}/badge/${monitor.tag}/status`;
pathMonitorBadgeDot = `${protocol}//${domain}${base}/badge/${monitor.tag}/dot`;
pathMonitorBadgeDotPing = `${protocol}//${domain}${base}/badge/${monitor.tag}/dot?animate=ping`;
analyticsEvent("monitor_share_menu_open", {
tag: monitor.tag
});
});
</script>
<div class="relative mb-2 scroll-m-20 px-4 pt-4 {!!monitor.image ? 'pl-10' : ''} text-xl font-medium tracking-tight">
{#if !!monitor.image}
<GMI src={monitor.image} classList="absolute left-4 top-5 inline h-5 w-5" alt={monitor.name} srcset="" />
{/if}
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
{monitor.name}
</p>
</div>
<div class="px-4">
<h2 class="mb-1 text-sm font-semibold">
{l(lang, "Share")}
</h2>
<p class="mb-2 text-xs text-muted-foreground">
{l(lang, "Share this monitor using a link with others")}
</p>
<Button class="h-8 px-2 pr-4 text-xs font-semibold" variant="secondary" on:click={copyLinkToClipboard}>
{#if !copiedLink}
<Link class="mr-2 inline" size={12} />
<span class="font-semibold">
{l(lang, "Copy Link")}
</span>
{:else}
<CopyCheck class="mr-2 inline" size={12} />
<span class="font-semibold">
{l(lang, "Link Copied")}
</span>
{/if}
</Button>
<Button
href={pathMonitorLink}
target="_blank"
variant="link"
class="h-8 px-3 text-xs font-semibold text-muted-foreground"
>
<ExternalLink class="inline" size={12} />
</Button>
</div>
<hr class="my-4" />
<div class="px-4">
<h2 class="mb-1 text-sm font-semibold">
{l(lang, "Embed")}
</h2>
<p class="mb-1 text-xs text-muted-foreground">
{l(lang, "Embed this monitor using &#x3C;script&#x3E; or &#x3C;iframe&#x3E; in your app.")}
</p>
<div class="mb-4 grid grid-cols-2 gap-2">
<div class="col-span-1">
<h3 class="mb-2 text-xs">
{l(lang, "Theme")}
</h3>
<RadioGroup.Root bind:value={theme} class=" flex">
<div class="flex items-center space-x-2">
<RadioGroup.Item value="light" id="light-theme" />
<Label class="text-xs" for="light-theme">{l(lang, "Light")}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="dark" id="dark-theme" />
<Label class="text-xs" for="dark-theme">{l(lang, "Dark")}</Label>
</div>
<RadioGroup.Input name="theme" />
</RadioGroup.Root>
</div>
<div class="col-span-1 pl-2">
<h3 class="mb-2 text-xs">
{l(lang, "Mode")}
</h3>
<RadioGroup.Root bind:value={embedType} class="flex">
<div class="flex items-center space-x-2">
<RadioGroup.Item value="js" id="js-embed" />
<Label class="text-xs" for="js-embed">&#x3C;script&#x3E;</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="iframe" id="iframe-embed" />
<Label class="text-xs" for="iframe-embed">&#x3C;iframe&#x3E;</Label>
</div>
<RadioGroup.Input name="embed" />
</RadioGroup.Root>
</div>
</div>
<Button class="h-8 px-2 pr-4 text-xs" variant="secondary" on:click={copyScriptTagToClipboard}>
{#if !copiedEmbed}
<Code class="mr-2 inline" size={12} />
<span class=" font-semibold">
{l(lang, "Copy Code")}
</span>
{:else}
<CopyCheck class="mr-2 inline" size={12} />
<span class="font-semibold">
{l(lang, "Code Copied")}
</span>
{/if}
</Button>
</div>
<hr class="my-4" />
<div class="px-4">
<h2 class="mb-1 text-sm font-semibold">
{l(lang, "Badge")}
</h2>
<p class="mb-2 text-xs text-muted-foreground">
{l(lang, "Get SVG badge for this monitor")}
</p>
<Button class="h-8 px-2 pr-4 text-xs" variant="secondary" on:click={copyStatusBadge}>
{#if !copiedBadgeStatus}
<TrendingUp class="mr-2 inline" size={12} />
<span class="font-semibold">
{l(lang, "Status")}
{l(lang, "Badge")}
</span>
{:else}
<CopyCheck class="mr-2 inline" size={12} />
<span class="font-semibold">
{l(lang, "Badge Copied")}
</span>
{/if}
</Button>
<Button class="h-8 px-2 pr-4 text-xs" variant="secondary" on:click={copyUptimeBadge}>
{#if !copiedBadgeUptime}
<Percent class="mr-2 inline" size={12} />
<span class="font-semibold">
{l(lang, "Uptime")}
{l(lang, "Badge")}
</span>
{:else}
<CopyCheck class="mr-2 inline" size={12} />
<span class="font-semibold">
{l(lang, "Badge Copied")}
</span>
{/if}
</Button>
</div>
<hr class="my-4" />
<div class="mb-4 px-4">
<h2 class="mb-1 text-sm font-semibold">
{l(lang, "LIVE Status")}
</h2>
<p class="mb-2 text-xs text-muted-foreground">
{l(lang, "Get a LIVE Status for this monitor")}
</p>
<Button class="h-8 px-2 pr-4 text-xs" variant="secondary" on:click={copyDotStandard}>
{#if !copiedBadgeDotStandard}
<img src={pathMonitorBadgeDot} class="mr-1 inline h-5" alt="status" />
<span class="font-semibold">
{l(lang, "Standard")}
</span>
{:else}
<CopyCheck class="mr-2 inline h-5 w-5" />
<span class="font-semibold">
{l(lang, "Standard")}
</span>
{/if}
</Button>
<Button class="h-8 px-2 pr-4 text-xs" variant="secondary" on:click={copyDotPing}>
{#if !copiedBadgeDotPing}
<img src={pathMonitorBadgeDotPing} class="mr-1 inline h-5" alt="status" />
<span class="font-semibold">
{l(lang, "Pinging")}
</span>
{:else}
<CopyCheck class="mr-2 inline h-5 w-5" />
<span class="font-semibold">
{l(lang, "Pinging")}
</span>
{/if}
</Button>
</div>
+366
View File
@@ -0,0 +1,366 @@
<script>
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Button } from "$lib/components/ui/button";
import { page } from "$app/state";
import { base } from "$app/paths";
import { onMount } from "svelte";
import GMI from "$lib/components/gmi.svelte";
import autoAnimate from "@formkit/auto-animate";
import Loader from "lucide-svelte/icons/loader";
import SquareChartGantt from "lucide-svelte/icons/square-chart-gantt";
import AlarmClockCheck from "lucide-svelte/icons/alarm-clock-check";
import Logout from "lucide-svelte/icons/log-out";
import ChevronLeft from "lucide-svelte/icons/chevron-left";
import { l } from "$lib/i18n/client";
let apiError = "";
let view = "loading";
let localStorageKey = "user_subscription";
let login = {
userEmail: "",
verifyWithToken: "",
verificationCode: ""
};
let subscribedMonitors = [];
let subsInfo = {
allMonitors: false,
monitors: [],
hasActiveSubscription: false
};
let subscribableMonitors = $page.data.subscribableMonitors;
let callingAPI = false;
let alreadySubscribed = false;
let subscriberData = null;
onMount(async () => {
// Try to fetch stored subscription data from localStorage
await fetchStoredSubscription();
//
});
async function createNewUserSubscription() {
callingAPI = true;
apiError = "";
let action = "subscribe";
try {
const response = await callAPI(action, {
token: localStorage.getItem(localStorageKey),
monitors: subsInfo.monitors,
allMonitors: subsInfo.allMonitors
});
await fetchStoredSubscription();
} catch (e) {
apiError = e.message;
} finally {
callingAPI = false;
}
}
async function removeSubscription() {
//use confirm
if (!confirm("Are you sure you want to unsubscribe?")) {
return;
}
callingAPI = true;
apiError = "";
let action = "unsubscribe";
try {
await callAPI(action, {
token: localStorage.getItem(localStorageKey)
});
await fetchStoredSubscription();
} catch (e) {
apiError = e.message;
} finally {
callingAPI = false;
}
}
async function callAPI(action, data) {
try {
const response = await fetch(`${base}/api/subscriptions/${action}`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(data)
});
if (response.ok) {
return await response.json();
} else {
let error = await response.json();
throw new Error(error.message || "An error occurred while processing your request.");
}
} catch (error) {
console.error("Error calling API:", error);
throw error;
}
}
async function doLogin() {
let action = "login";
callingAPI = true;
apiError = "";
try {
const response = await callAPI(action, {
userEmail: login.userEmail
});
if (response) {
login.verifyWithToken = response.token;
login.verificationCode = "";
view = "verify_login";
}
} catch (e) {
apiError = e.message;
} finally {
callingAPI = false;
}
}
async function verifyLogin() {
let action = "verify";
callingAPI = true;
apiError = "";
try {
const response = await callAPI(action, {
token: login.verifyWithToken,
code: login.verificationCode
});
if (response) {
localStorage.setItem(localStorageKey, response.token);
await fetchStoredSubscription();
}
} catch (e) {
apiError = e.message;
} finally {
callingAPI = false;
}
}
async function fetchStoredSubscription() {
let action = "fetch";
view = "loading";
let token = localStorage.getItem(localStorageKey);
if (!!!token) {
view = "login";
return;
}
try {
const response = await callAPI(action, {
token: token
});
if (response) {
subsInfo.email = response.email;
view = "subscribe";
subsInfo.monitors = response.monitors;
if (subsInfo.monitors && subsInfo.monitors.length > 0) {
subsInfo.hasActiveSubscription = true;
//if parsedData.monitors contains "_", set allMonitors to true
const allMonitorsIncluded = subsInfo.monitors.includes("_");
if (allMonitorsIncluded) {
subsInfo.allMonitors = true;
} else {
subsInfo.allMonitors = false;
subsInfo.monitors = subsInfo.monitors.filter((m) => m !== "_");
}
} else {
subsInfo.allMonitors = false;
subsInfo.monitors = [];
subsInfo.hasActiveSubscription = false; // Updated to set hasActiveSubscription
}
}
} catch (e) {
view = "login";
}
}
function doLogout() {
localStorage.removeItem(localStorageKey);
login.userEmail = "";
login.verificationCode = "";
login.verifyWithToken = "";
view = "login";
}
</script>
<div class="flex flex-col gap-2 pb-4" use:autoAnimate>
<div class="relative mb-2 scroll-m-20 px-4 pt-4 text-xl font-medium tracking-tight">
<p>
{l($page.data.lang, "Manage Subscription")}
</p>
</div>
{#if view === "login"}
<div class="flex flex-col gap-2 px-4">
<div>
<p class="text-muted-foreground mb-2 text-xs font-semibold">
{l(
$page.data.lang,
"Enter your email to log in. If you are not subscribed, you will be prompted to subscribe."
)}
</p>
<div class="relative">
<Input type="email" placeholder={l($page.data.lang, "Enter your email")} bind:value={login.userEmail} />
</div>
</div>
</div>
<div class="mt-2 flex flex-col gap-2 px-4">
{#if apiError}
<p class="text-destructive text-xs font-semibold">{apiError}</p>
{/if}
<Button class="w-full" disabled={callingAPI || !login.userEmail} on:click={doLogin}>
{l($page.data.lang, "Login")}
{#if callingAPI}
<Loader class="ml-2 h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
{/if}
{#if view === "loading"}
<div class="flex h-20 justify-center align-middle">
<Loader class="mt-5 h-10 w-10 animate-spin" />
</div>
{/if}
{#if view === "subscribe"}
<div class="flex flex-col gap-2 px-4">
{#if subsInfo.email}
<div
class="stroke-secondary-foreground text-muted-foreground flex justify-between rounded-md py-2 text-xs font-medium"
>
<span class="text-muted-foreground mt-1 text-xs font-semibold">
{l($page.data.lang, "You are logged in as %email", { email: subsInfo.email })}
</span>
<div>
<Button
variant="ghost"
size="icon"
class=" text-muted-foreground h-6 w-6 justify-center text-xs font-semibold"
on:click={doLogout}
>
<Logout class=" h-4 w-4" />
</Button>
</div>
</div>
{/if}
<div class="mt-2">
<label class="flex w-full cursor-pointer items-center justify-between">
<span class="text-sm font-medium">{l($page.data.lang, "Subscribe to all monitors")}</span>
<input
type="checkbox"
value=""
class="peer sr-only"
checked={subsInfo.allMonitors}
on:change={(e) => {
subsInfo.allMonitors = e.target.checked;
if (e.target.checked) {
subsInfo.monitors = [];
}
}}
/>
<div
class="peer relative h-6 w-11 rounded-full bg-gray-200 peer-checked:bg-blue-600 peer-focus:outline-none after:absolute after:start-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white rtl:peer-checked:after:-translate-x-full dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800"
></div>
</label>
</div>
</div>
{#if subscribableMonitors.length > 0 && !subsInfo.allMonitors}
<div class="flex flex-col gap-2">
<p class="px-4">
<span class="text-muted-foreground text-xs font-semibold">
{l($page.data.lang, "Please select specific monitors to receive updates from.")}
</span>
</p>
<div class="flex max-h-96 flex-col gap-2 overflow-y-auto px-4">
{#each subscribableMonitors as monitor}
<div class="flex items-center justify-between gap-2 border-b pb-2 last:border-0">
<label for={monitor.tag} class="flex gap-x-2 text-sm font-medium text-gray-900 dark:text-gray-300">
{#if !!monitor.image}
<GMI src={monitor.image} classList="mt-1 h-4 w-4" />
{:else}
<SquareChartGantt class="mt-1 h-4 w-4" />
{/if}
{monitor.name}
</label>
<input
type="checkbox"
on:change={(e) => {
if (e.target.checked) {
subsInfo.monitors.push(monitor.tag);
} else {
subsInfo.monitors = subsInfo.monitors.filter((tag) => tag !== monitor.tag);
}
}}
id={monitor.tag}
value={monitor.tag}
bind:group={subsInfo.monitors}
class="h-4 w-4 cursor-pointer rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800 dark:focus:ring-blue-600"
/>
</div>
{/each}
</div>
</div>
{/if}
<div class="mt-2 flex flex-col gap-2 px-4">
{#if apiError}
<p class="text-destructive text-xs font-semibold">{apiError}</p>
{/if}
<Button class="w-full" disabled={callingAPI} on:click={createNewUserSubscription}>
{#if subsInfo.monitors.length > 0}
{l($page.data.lang, "Update Subscription")}
{:else}
{l($page.data.lang, "Subscribe")}
{/if}
{#if callingAPI}
<Loader class="ml-2 h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
{/if}
{#if view === "verify_login"}
<div class="flex flex-col gap-2 px-4">
<div>
<Button
variant="outline"
class="bounce-left text-muted-foreground mb-2 h-8 justify-start pl-1.5 text-xs"
on:click={() => (view = "login")}
>
<ChevronLeft class="arrow mr-1 h-5 w-5" />
{l($page.data.lang, "Change Email")}
</Button>
<p class="text-muted-foreground mb-2 text-xs">
{l($page.data.lang, "We have sent a code to your email. Please enter it below to confirm your login")}
</p>
<Input type="text" placeholder={l($page.data.lang, "Enter the code")} bind:value={login.verificationCode} />
</div>
</div>
<div class="mt-2 flex flex-col gap-2 px-4">
{#if apiError}
<p class="text-destructive text-xs font-semibold">{apiError}</p>
{/if}
<Button class="w-full" disabled={callingAPI || !login.verificationCode} on:click={verifyLogin}>
{l($page.data.lang, "Confirm Login")}
{#if callingAPI}
<Loader class="ml-2 h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
{/if}
</div>
@@ -0,0 +1,22 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithoutChild<AccordionPrimitive.ContentProps> = $props();
</script>
<AccordionPrimitive.Content
bind:ref
data-slot="accordion-content"
class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...restProps}
>
<div class={cn("pt-0 pb-4", className)}>
{@render children?.()}
</div>
</AccordionPrimitive.Content>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AccordionPrimitive.ItemProps = $props();
</script>
<AccordionPrimitive.Item
bind:ref
data-slot="accordion-item"
class={cn("border-b last:border-b-0", className)}
{...restProps}
/>
@@ -0,0 +1,32 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
level = 3,
children,
...restProps
}: WithoutChild<AccordionPrimitive.TriggerProps> & {
level?: AccordionPrimitive.HeaderProps["level"];
} = $props();
</script>
<AccordionPrimitive.Header {level} class="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
bind:ref
class={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-start text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon
class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
@@ -0,0 +1,16 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: AccordionPrimitive.RootProps = $props();
</script>
<AccordionPrimitive.Root
bind:ref
bind:value={value as never}
data-slot="accordion"
{...restProps}
/>
+16
View File
@@ -0,0 +1,16 @@
import Root from "./accordion.svelte";
import Content from "./accordion-content.svelte";
import Item from "./accordion-item.svelte";
import Trigger from "./accordion-trigger.svelte";
export {
Root,
Content,
Item,
Trigger,
//
Root as Accordion,
Content as AccordionContent,
Item as AccordionItem,
Trigger as AccordionTrigger,
};
@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-description"
class={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-title"
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
{...restProps}
>
{@render children?.()}
</div>
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const alertVariants = tv({
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
});
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
} = $props();
</script>
<div
bind:this={ref}
data-slot="alert"
class={cn(alertVariants({ variant }), className)}
{...restProps}
role="alert"
>
{@render children?.()}
</div>
+14
View File
@@ -0,0 +1,14 @@
import Root from "./alert.svelte";
import Description from "./alert-description.svelte";
import Title from "./alert-title.svelte";
export { alertVariants, type AlertVariant } from "./alert.svelte";
export {
Root,
Description,
Title,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle,
};
@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.FallbackProps = $props();
</script>
<AvatarPrimitive.Fallback
bind:ref
data-slot="avatar-fallback"
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
{...restProps}
/>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.ImageProps = $props();
</script>
<AvatarPrimitive.Image
bind:ref
data-slot="avatar-image"
class={cn("aspect-square size-full", className)}
{...restProps}
/>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
loadingStatus = $bindable("loading"),
class: className,
...restProps
}: AvatarPrimitive.RootProps = $props();
</script>
<AvatarPrimitive.Root
bind:ref
bind:loadingStatus
data-slot="avatar"
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
{...restProps}
/>
+13
View File
@@ -0,0 +1,13 @@
import Root from "./avatar.svelte";
import Image from "./avatar-image.svelte";
import Fallback from "./avatar-fallback.svelte";
export {
Root,
Image,
Fallback,
//
Root as Avatar,
Image as AvatarImage,
Fallback as AvatarFallback,
};
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
variants: {
variant: {
default:
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
destructive:
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>
+2
View File
@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
@@ -0,0 +1,23 @@
<script lang="ts">
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
</script>
<span
bind:this={ref}
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
class={cn("flex size-9 items-center justify-center", className)}
{...restProps}
>
<EllipsisIcon class="size-4" />
<span class="sr-only">More</span>
</span>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLLiAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLLiAttributes> = $props();
</script>
<li
bind:this={ref}
data-slot="breadcrumb-item"
class={cn("inline-flex items-center gap-1.5", className)}
{...restProps}
>
{@render children?.()}
</li>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import type { Snippet } from "svelte";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
href = undefined,
child,
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
child?: Snippet<[{ props: HTMLAnchorAttributes }]>;
} = $props();
const attrs = $derived({
"data-slot": "breadcrumb-link",
class: cn("hover:text-foreground transition-colors", className),
href,
...restProps,
});
</script>
{#if child}
{@render child({ props: attrs })}
{:else}
<a bind:this={ref} {...attrs}>
{@render children?.()}
</a>
{/if}

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