mirror of
https://github.com/rajnandan1/kener.git
synced 2026-06-23 04:10:22 +00:00
first commit for version 4, very unstable
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.vscode
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
@@ -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
|
||||||
@@ -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/)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
github: rajnandan1
|
||||||
|
buy_me_a_coffee: rajnandan1
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
database folder
|
||||||
+45
@@ -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;
|
||||||
@@ -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"));
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
}
|
||||||
Generated
+13555
File diff suppressed because it is too large
Load Diff
+167
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+20
@@ -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 {};
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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 }
|
||||||
|
]
|
||||||
@@ -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>`;
|
||||||
@@ -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 |
@@ -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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Client-only types (safe for +page.ts / components).
|
||||||
|
|
||||||
|
export type ThemeMode = "light" | "dark" | "system";
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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} />
|
||||||
@@ -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>
|
||||||
@@ -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} />
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 <script> or <iframe> 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"><script></Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<RadioGroup.Item value="iframe" id="iframe-embed" />
|
||||||
|
<Label class="text-xs" for="iframe-embed"><iframe></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>
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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
Reference in New Issue
Block a user