Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d475ab23f | |||
| 01cb39e18e | |||
| 0d0ef25970 | |||
| 6c766e2001 | |||
| 1c73166120 | |||
| fc5dbc85dc | |||
| 622327cdf6 | |||
| b0a4cd5c42 | |||
| 4e5fb26be1 | |||
| 6fc80bc0fc | |||
| 292667ac29 | |||
| 8c03058f8d | |||
| 25e73d097c | |||
| 1970cd8891 | |||
| 8ae9b2dec1 | |||
| 1662608984 | |||
| 42693983f1 | |||
| f43048d783 | |||
| b5ec332b4a | |||
| dab1c44b18 | |||
| d9c0bff780 | |||
| beceace2c2 | |||
| 5373636704 | |||
| e8d04eccf6 | |||
| d978c82263 | |||
| 883a458ed3 | |||
| 9009d9df99 | |||
| 81c0fa1243 | |||
| 01221616ae | |||
| be75a0f5e9 | |||
| 1b40839490 | |||
| 80f5780602 | |||
| dcf1817cd6 | |||
| ae251803ad | |||
| f6d9627ceb | |||
| 0cbc6b7b56 | |||
| f0af2d14e4 | |||
| b797039144 | |||
| 86e29e5065 | |||
| fddef22e8e | |||
| ad7551b7af | |||
| f09eb18bcc | |||
| 5c65e35f24 | |||
| 8cbe859971 | |||
| 67f38db847 | |||
| 28a72a3592 | |||
| dae334694d | |||
| e57fe3a046 | |||
| 54670c7845 | |||
| 3dbcef1500 | |||
| 24bbad05cb | |||
| cacbda3164 | |||
| da9b1a3a64 | |||
| 394b911eca | |||
| 220778e84f | |||
| c6e343da5d | |||
| 268e5b7478 | |||
| b9ccd16a02 | |||
| 302ad0e8a6 | |||
| e052f435db | |||
| caa1330f39 | |||
| 870d19f566 | |||
| a28f787833 | |||
| 335a51b589 | |||
| 72f9471486 | |||
| f0cd101af5 | |||
| 52876bd77e | |||
| aabe1926bd | |||
| e785227064 | |||
| 0cb88eec3b | |||
| 8cd2914f73 | |||
| f70e2ee8eb | |||
| c60a21ef32 | |||
| 77a57ee609 | |||
| fc15f0e083 | |||
| 6115beece3 | |||
| 54434fbe78 | |||
| 83755bdd25 | |||
| 08f901c5f0 | |||
| 06910fbd4d | |||
| 154e7dd185 | |||
| 8bde3226bf | |||
| e5565145b5 | |||
| 37a667daff | |||
| eda98bacfc | |||
| ee1ee52e13 | |||
| d03bf41ad1 | |||
| ff864fbaab | |||
| f583ba4938 | |||
| d552f541ac | |||
| d46d02e37a | |||
| a7c27a60e0 | |||
| 974976bd90 | |||
| 9bbe665984 | |||
| d0ea8551b6 | |||
| d03a8fd7a4 | |||
| c00aae5566 | |||
| e923f4d650 | |||
| 654c07a364 | |||
| 820eeb0aac | |||
| 2a338baa26 | |||
| ec7351272f | |||
| 13dec43ef3 | |||
| 43673e3349 | |||
| 1e77253a63 | |||
| d349a7591e | |||
| 1dd05fee50 | |||
| 374eda4103 | |||
| 52a8cca3e8 | |||
| 7176c3d4f4 | |||
| 13366284c6 | |||
| fdad329148 | |||
| 73bf5f3fbe |
@@ -1,4 +1,7 @@
|
||||
node_modules
|
||||
.git
|
||||
.github
|
||||
.vscode
|
||||
dist
|
||||
build
|
||||
.env
|
||||
|
||||
@@ -33,6 +33,11 @@ trim_trailing_whitespace = false
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
# CSS & PostCSS files
|
||||
[*.{css,postcss}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
# Markdown files
|
||||
[*.md]
|
||||
indent_style = space
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
KENER_SECRET_KEY=please_change_me
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
KENER_BASE_PATH=""
|
||||
RESEND_API_KEY=
|
||||
ORIGIN=http://localhost:3000
|
||||
TZ=Etc/UTC
|
||||
KENER_SECRET_KEY=please_change_me_to_something_secure
|
||||
|
||||
# For SQLite database...
|
||||
DATABASE_URL=sqlite://./database/kener.sqlite.db
|
||||
TZ=UTC
|
||||
|
||||
# For PostgreSQL database...
|
||||
# DATABASE_URL=postgresql://db_user:db_password@localhost:5432/kener_db
|
||||
# POSTGRES_PASSWORD=some_super_random_secure_password
|
||||
|
||||
# For MySQL database...
|
||||
# DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/kener_db
|
||||
# MYSQL_PASSWORD=some_super_random_secure_password
|
||||
|
||||
KENER_BASE_PATH=""
|
||||
ORIGIN=http://localhost:3000
|
||||
|
||||
RESEND_API_KEY=""
|
||||
RESEND_SENDER_EMAIL=Accounts <accounts@resend.dev>
|
||||
|
||||
# DATABASE_URL=postgresql://myuser:mypassword@localhost:5432/mydatabase
|
||||
# DATABASE_URL=mysql://root:password@127.0.0.1:3306/kener
|
||||
# Likely no need to change...
|
||||
# NODE_ENV=production # already defined in container
|
||||
# PORT=3000 # default port Kener service is exposed upon
|
||||
|
||||
# Add the below variable if you would like to ‘white-label’ the product (aka. remove some of the attributions scattered throughout the app)
|
||||
# WHITE_LABEL=true
|
||||
@@ -23,6 +23,9 @@ 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.
|
||||
|
||||
|
||||
@@ -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,157 @@
|
||||
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: '20' # 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: |
|
||||
# Tag non-main branches (...for Alpine variant, add suffix)
|
||||
type=ref,event=branch,suffix=-${{ matrix.variant }},enable=${{ matrix.variant == 'alpine' && github.ref != 'refs/heads/main' }}
|
||||
type=ref,event=branch,enable=${{ matrix.variant == 'debian' && github.ref != 'refs/heads/main' }}
|
||||
# Tag matrix.variant (Alpine) releases as version (1.0.0), major.minor (1.0), major (1), and matrix.variant (alpine)
|
||||
type=semver,pattern={{version}},suffix=-${{ matrix.variant }},enable=${{ matrix.variant == 'alpine' }}
|
||||
type=semver,pattern={{major}}.{{minor}},suffix=-${{ matrix.variant }},enable=${{ matrix.variant == 'alpine' }}
|
||||
type=semver,pattern={{major}},suffix=-${{ matrix.variant }},enable=${{ matrix.variant == 'alpine' }}
|
||||
type=raw,value=${{ matrix.variant }},enable=${{ matrix.variant == 'alpine' && github.ref == 'refs/heads/main' }}
|
||||
# Tag default (Debian) releases as version (1.0.0), major.minor (1.0), major (1), and latest
|
||||
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' && github.ref == 'refs/heads/main' }}
|
||||
|
||||
- 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 }}"
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
@@ -1,61 +0,0 @@
|
||||
---
|
||||
name: Publish Docker image to Dockerhub and GHCR
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "*.*.*"
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'docs/**'
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ vars.DOCKERHUB_IMAGE_NAME != '' || vars.GHCR_IMAGE_NAME != '' }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
- name: Log in to Docker Hub
|
||||
if: ${{ github.event_name != 'pull_request' && vars.DOCKERHUB_IMAGE_NAME != ''
|
||||
}}
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && vars.GHCR_IMAGE_NAME != '' }}
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
images: |
|
||||
${{ vars.DOCKERHUB_IMAGE_NAME }}
|
||||
${{ vars.GHCR_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ endsWith(github.ref, 'main') }}
|
||||
type=ref,event=branch,enable=${{ !endsWith(github.ref, 'main') }}
|
||||
type=semver,pattern={{version}}
|
||||
flavor: |
|
||||
latest=false
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' && !env.ACT}}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -14,7 +14,7 @@
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100
|
||||
"printWidth": 120
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -24,7 +24,7 @@
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 80
|
||||
"printWidth": 120
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -34,7 +34,7 @@
|
||||
"semi": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100
|
||||
"printWidth": 120
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,52 +1,148 @@
|
||||
FROM node:23
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Install necessary packages including tzdata for timezone setting
|
||||
# Global build arguments
|
||||
ARG ALPINE_VERSION=23.7.0-alpine3.21
|
||||
ARG DEBIAN_VERSION=23.7.0-bookworm-slim
|
||||
ARG VARIANT=debian
|
||||
|
||||
#==========================================================#
|
||||
# STAGE 1: BUILD STAGE #
|
||||
#==========================================================#
|
||||
|
||||
FROM node:${DEBIAN_VERSION} AS builder-debian
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
libnode108 \
|
||||
nodejs \
|
||||
python3 \
|
||||
sqlite3 \
|
||||
libsqlite3-dev \
|
||||
make \
|
||||
node-gyp \
|
||||
g++ \
|
||||
tzdata \
|
||||
iputils-ping && \
|
||||
build-essential=12.9 \
|
||||
python3=3.11.2-1+b1 \
|
||||
sqlite3=3.40.1-2+deb12u1 \
|
||||
libsqlite3-dev=3.40.1-2+deb12u1 \
|
||||
make=4.3-4.1 \
|
||||
node-gyp=9.3.0-2 \
|
||||
g++=4:12.2.0-3 \
|
||||
tzdata=2024b-0+deb12u1 \
|
||||
iputils-ping=3:20221126-1+deb12u1 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set the timezone environment variable and the application environment
|
||||
ARG KENER_BASE_PATH=
|
||||
ENV TZ=Etc/UTC
|
||||
ENV KENER_BASE_PATH=${KENER_BASE_PATH}
|
||||
FROM node:${ALPINE_VERSION} AS builder-alpine
|
||||
RUN apk add --no-cache --update \
|
||||
build-base=0.5-r3 \
|
||||
python3=3.12.9-r0 \
|
||||
py3-pip=24.3.1-r0 \
|
||||
make=4.4.1-r2 \
|
||||
g++=14.2.0-r4 \
|
||||
sqlite=3.48.0-r0 \
|
||||
sqlite-dev=3.48.0-r0 \
|
||||
tzdata \
|
||||
iputils=20240905-r0
|
||||
|
||||
FROM builder-${VARIANT} AS builder
|
||||
|
||||
# Set environment variables
|
||||
ENV NPM_CONFIG_LOGLEVEL=error \
|
||||
VITE_BUILD_ENV=production
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and install dependencies
|
||||
# Copy package files for dependency installation
|
||||
COPY package*.json ./
|
||||
RUN npm install && npm cache clean --force
|
||||
|
||||
# Copy the rest of the application code
|
||||
# Install all dependencies, including `devDependencies` (cache enabled for faster builds)
|
||||
# TODO: Possibly add `--no-audit` flag to `npm ci` to prevent `npm` from running a security audit on installed packages. (By default, `npm install` performs an audit to check for vulnerabilities in dependencies, which can slow down installation. Adding this flag would skip the audit, thus making `npm install` significantly faster for the CI/CD pipeline.)
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --no-fund && \
|
||||
npm cache clean --force
|
||||
|
||||
# Copy application source code
|
||||
COPY . .
|
||||
|
||||
# remove dir src/routes/(docs)
|
||||
RUN rm -rf src/routes/\(docs\)
|
||||
# TODO: Reevaluate permissions (possibly reduce?)...
|
||||
# Remove docs directory and ensure required directories exist
|
||||
RUN rm -rf src/routes/\(docs\) \
|
||||
static/documentation \
|
||||
static/fonts/lato/full && \
|
||||
mkdir -p uploads database && \
|
||||
# TODO: Consider changing below to `chmod -R u-rwX,g=rX,o= uploads database`
|
||||
chmod -R 750 uploads database
|
||||
|
||||
# Ensure /app/uploads and /app/database have rw permissions
|
||||
RUN mkdir -p /app/uploads /app/database && \
|
||||
chmod -R 777 /app/uploads /app/database
|
||||
# Build the application and remove `devDependencies`
|
||||
RUN npm run build && \
|
||||
npm prune --omit=dev
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
#==========================================================#
|
||||
# STAGE 2: PRODUCTION/FINAL STAGE #
|
||||
#==========================================================#
|
||||
|
||||
# Argument for the port
|
||||
ARG PORT=3000
|
||||
# Set the environment variable for the port
|
||||
ENV PORT=$PORT
|
||||
FROM node:${DEBIAN_VERSION} AS final-debian
|
||||
# TODO: Consider adding `--no-install-recommends`, but will need testing (may further help reduce final build size)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
iputils-ping=3:20221126-1+deb12u1 \
|
||||
sqlite3=3.40.1-2+deb12u1 \
|
||||
tzdata=2024b-0+deb12u1 \
|
||||
# TODO: Is it ok to change to `curl` here so that we don't have to maintain `wget` version mismatch between Debian architectures? (`curl` is only used for the container healthcheck and because there is an Alpine variant (best!) we probably don't care if the Debian image ends up building bigger due to `curl`.)
|
||||
curl=7.88.1-10+deb12u8 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM node:${ALPINE_VERSION} AS final-alpine
|
||||
RUN apk add --no-cache --update \
|
||||
iputils=20240905-r0 \
|
||||
sqlite=3.48.0-r0 \
|
||||
tzdata
|
||||
|
||||
FROM final-${VARIANT} AS final
|
||||
|
||||
ARG PORT=3000 \
|
||||
USERNAME=node
|
||||
|
||||
# Set environment variables
|
||||
ENV HEALTHCHECK_PORT=$PORT \
|
||||
HEALTHCHECK_PATH= \
|
||||
NODE_ENV=production \
|
||||
NPM_CONFIG_LOGLEVEL=error \
|
||||
PORT=$PORT \
|
||||
TZ=Etc/UTC
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files build artifacts, and necessary files from builder stage
|
||||
COPY --chown=node:node --from=builder /app/src/lib/ ./src/lib/
|
||||
COPY --chown=node:node --from=builder /app/build ./build
|
||||
COPY --chown=node:node --from=builder /app/uploads ./uploads
|
||||
COPY --chown=node:node --from=builder /app/database ./database
|
||||
# TODO: Consider changing from copying `node_modules` to instead letting `npm ci --omit=dev` handle production dependencies. Right now, copying `node_modules` is leading to a smaller image, whereas letting `npm ci` handle the install in final image is slightly faster, but leads to larger image size. IMO, having a slightly longer build time (e.g. ~10 sec.) is better in the end to have a smaller image.
|
||||
COPY --chown=node:node --from=builder /app/node_modules ./node_modules
|
||||
COPY --chown=node:node --from=builder /app/migrations ./migrations
|
||||
COPY --chown=node:node --from=builder /app/seeds ./seeds
|
||||
COPY --chown=node:node --from=builder /app/static ./static
|
||||
COPY --chown=node:node --from=builder /app/entrypoint.sh ./entrypoint.sh
|
||||
COPY --chown=node:node --from=builder /app/knexfile.js ./knexfile.js
|
||||
COPY --chown=node:node --from=builder /app/main.js ./main.js
|
||||
COPY --chown=node:node --from=builder /app/openapi.json ./openapi.json
|
||||
COPY --chown=node:node --from=builder /app/openapi.yaml ./openapi.yaml
|
||||
|
||||
# Ensure necessary directories are writable
|
||||
VOLUME ["/uploads", "/database"]
|
||||
|
||||
# Set container timezone and make entrypoint script executable
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone && \
|
||||
chmod +x ./entrypoint.sh
|
||||
# TODO: To improve security, consider dropping unnecessary capabilities instead of granting image all network capabilities of host. (Maybe `setcap cap_net_raw+p /usr/bin/ping`, etc.) Could also drop all and then grant only the capabilities that are explicitly needed. Some examples are commented out below...
|
||||
# setcap cap_net_bind_service=+ep /usr/local/bin/node
|
||||
# setcap cap_net_bind_service=+ep /usr/bin/ping
|
||||
# setcap cap_net_bind_service=+ep /usr/bin/ping6
|
||||
# setcap cap_net_bind_service=+ep /usr/bin/tracepath
|
||||
# setcap cap_net_bind_service=+ep /usr/bin/clockdiff
|
||||
|
||||
# Expose the application port
|
||||
EXPOSE $PORT
|
||||
|
||||
# Set the command to run the application
|
||||
# Add a healthcheck to the container; `wget` vs. `curl` depending on base image. Using this approach because `wget` does not actually maintain versioning across architectures, so we cannot pin a `wget` version (in above `final-debian` base, `apt-get install`) between differing architectures (e.g. arm64, amd64)
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD sh -c 'if [ -f "/etc/alpine-release" ]; then wget --quiet --spider http://localhost:$HEALTHCHECK_PORT$HEALTHCHECK_PATH || exit 1; else curl --silent --head --fail http://localhost:$HEALTHCHECK_PORT$HEALTHCHECK_PATH || exit 1; fi'
|
||||
|
||||
# TODO: Revisit letting user define $PUID & $PGID overrides (e.g. `addgroup -g $PGID newgroup && adduser -D -G newgroup -u $PUID node`) as well as potentially ensure no root user exists. (Make sure no processes are running as root, first!)
|
||||
# Use a non-root user (recommended for security)
|
||||
USER $USERNAME
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
CMD ["node", "main"]
|
||||
@@ -7,16 +7,21 @@
|
||||
<p align="center">
|
||||
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/rajnandan1/kener?label=Star%20Repo&style=social">
|
||||
<a href="https://github.com/ivbeg/awesome-status-pages"><img src="https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg" alt="Awesome status page" /></a>
|
||||
<a href="https://hub.docker.com/r/rajnandan1/kener"><img src="https://img.shields.io/docker/pulls/rajnandan1/kener" alt="Docker Kener" /></a>
|
||||
<a href="https://awesome-selfhosted.net/tags/status--uptime-pages.html#kener"><img src="https://awesome.re/mentioned-badge.svg" alt="Awesome self hosted" /></a>
|
||||
<a href="https://awesome-selfhosted.net/tags/status--uptime-pages.html#kener"><img src="https://awesome.re/mentioned-badge.svg" alt="Awesome self hosted" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/rajnandan1/kener/actions/workflows/publishImage.yml"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/rajnandan1/kener/publishImage.yml" /></a>
|
||||
<a href="https://hub.docker.com/r/rajnandan1/kener"><img src="https://img.shields.io/docker/pulls/rajnandan1/kener" alt="Docker Kener" /></a>
|
||||
<a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=latest"><img alt="Docker Image Size" src="https://img.shields.io/docker/image-size/rajnandan1/kener/latest?logo=docker&logoColor=white&label=debian" /></a>
|
||||
<a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=alpine"><img alt="Docker Image Size" src="https://img.shields.io/docker/image-size/rajnandan1/kener/alpine?logo=docker&logoColor=white&label=alpine" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/rajnandan1/kener/actions/workflows/publish-images.yml"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/rajnandan1/kener/publish-images.yml" /></a>
|
||||
<a href="https://github.com/rajnandan1/kener/commit/HEAD"><img src="https://img.shields.io/github/last-commit/rajnandan1/kener/main" alt="" /></a>
|
||||
<a href="https://github.com/rajnandan1/kener/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/rajnandan1/kener.svg" /></a>
|
||||
</p>
|
||||
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.producthunt.com/posts/kener-2" target="_blank">
|
||||
<img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=kener-2&theme=light" alt="Kener on Product Hunt">
|
||||
@@ -43,13 +48,19 @@
|
||||
|
||||
## What is Kener?
|
||||
|
||||
Kener is status page system built with Sveltekit and NodeJS. It does not try to replace the Datadogs and Atlassian of the world. It tries to help some who wants to come up with a status page that looks nice and minimum overhead, in a modern way.
|
||||
**Kener** is a sleek and lightweight status page system built with **SvelteKit** and **NodeJS**. It’s not here to replace heavyweights like Datadog or Atlassian but rather to offer a simple, modern, and hassle-free way to set up a great-looking status page with minimal effort.
|
||||
|
||||
It is carefully crafted to be easy to use and customize.
|
||||
Designed with **ease of use** and **customization in mind**, Kener provides all the essential features you’d expect from a status page—without unnecessary complexity.
|
||||
|
||||
It comes with all the basic asks for a status page. It is open-source and free to use.
|
||||
### Why Kener?
|
||||
|
||||
Kener name is derived from the word "Kene" which means "how is it going" in Assamese, then .ing because it was a cheaply available domain.
|
||||
✅ Minimal overhead – Set up quickly with a clean, modern UI<br>
|
||||
✅ Customizable – Easily tailor it to match your brand<br>
|
||||
✅ Open-source & free – Because great tools should be accessible to everyone
|
||||
|
||||
### What's in a Name?
|
||||
|
||||
“Kener” is inspired by the Assamese word _“Kene”_, meaning _“how’s it going?”_. The _‘.ing’_ was added because, well… that domain was available. 😄
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -66,8 +77,73 @@ npm run dev
|
||||
|
||||
### Docker
|
||||
|
||||
The latest image is available on DockerHub at [`rajnandan1/kener:latest`](https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=latest).
|
||||
Download and use the sample [docker-compose.yml](https://github.com/rajnandan1/kener/blob/main/docker-compose.yml).
|
||||
Official Docker images for **Kener** are available on [Docker Hub](https://hub.docker.com/r/rajnandan1/kener). Multiple versions are maintained to support different use cases.
|
||||
|
||||
<a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.1.6"><img src="https://img.shields.io/badge/Latest_Stable_Release-3.1.6-blue" alt="Kener latest stable version: 3.1.6" /></a>
|
||||
|
||||
#### Available Tags
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Image Tag</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" colspan="2" style="color:#A81D33;text-align:left;">Debian 12 <small>(Bookwork Slim)</small> w/ Node.js v23.7.0 <strong><em>(default)</em></strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=latest" target="_blank"><code>latest</code></td>
|
||||
<td>Latest stable release (aka 3.1.6)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.1.6" target="_blank"><code>3.1.6</code></a></td>
|
||||
<td>Specific release version</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.1" target="_blank"><code>3.1</code></a></td>
|
||||
<td>Major-minor version tag pointing to the latest patch (3.1.6) release within that minor version (3.1.x)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3" target="_blank"><code>3</code></a></td>
|
||||
<td>Major version tag pointing to the latest stable (3.1.6) release within that major version (3.x.x)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" colspan="2" style="color:#0D597F;text-align:left;">Alpine Linux 3.21 w/ Node.js v23.7.0 <strong><em>(smallest image size)</em></strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=alpine" target="_blank"><code>alpine</code></td>
|
||||
<td>Latest stable release (aka 3.1.6)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.1.6-alpine" target="_blank"><code>3.1.6-alpine</code></a></td>
|
||||
<td>Specific release version</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.1-alpine" target="_blank"><code>3.1-alpine</code></a></td>
|
||||
<td>Major-minor version tag pointing to the latest patch (3.1.6) release within that minor version (3.1.x)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3-alpine" target="_blank"><code>3-alpine</code></a></td>
|
||||
<td>Major version tag pointing to the latest stable (3.1.6) release within that major version (3.x.x)</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
#### Usage
|
||||
|
||||
Pull the latest stable version:
|
||||
|
||||
```sh
|
||||
docker pull rajnandan1/kener:latest
|
||||
```
|
||||
|
||||
Or use the smaller, Alpine-based variant:
|
||||
|
||||
```sh
|
||||
docker pull rajnandan1/kener:alpine
|
||||
```
|
||||
|
||||
For a production setup, refer to the sample [docker-compose.yml](https://github.com/rajnandan1/kener/blob/main/docker-compose.yml).
|
||||
This keeps things clean, structured, and easy to read while preserving all the details.
|
||||
|
||||
### One Click
|
||||
|
||||
@@ -77,67 +153,64 @@ Download and use the sample [docker-compose.yml](https://github.com/rajnandan1/k
|
||||
|
||||
Here are some of the features that you get out of the box. Please read the documentation to know how to use them.
|
||||
|
||||
### Monitoring and Tracking
|
||||
### 📊 Monitoring and Tracking
|
||||
|
||||
- Advanced application performance monitoring tools
|
||||
- Real-time network monitor software capabilities
|
||||
- Polls HTTP endpoint or Push data to monitor using Rest APIs
|
||||
- Adjusts Timezones for visitors
|
||||
- Categorize Monitors into different Sections
|
||||
- Cron-based scheduling for monitors. Minimum per minute
|
||||
- Construct complex API Polls - Chain, Secrets etc
|
||||
- Supports a Default Status for Monitors
|
||||
- Supports base path for hosting in k8s
|
||||
- Pre-built docker image for easy deployment
|
||||
- Automatically adjusts timezones for visitors
|
||||
- Advanced **application performance monitoring** tools
|
||||
- **Real-time network monitoring** capabilities
|
||||
- Supports **polling HTTP endpoints** or **pushing data** via REST APIs
|
||||
- **Timezone auto-adjustment** for visitors
|
||||
- Organize monitors into **custom sections**
|
||||
- **Cron-based scheduling** (minimum: **every minute**)
|
||||
- **Create complex API polls** (chaining, secrets, etc.)
|
||||
- Set a **default status** for monitors
|
||||
- Supports **base path hosting in Kubernetes (k8s)**
|
||||
- **Pre-built Docker images** for easy deployment
|
||||
|
||||
### Customization and Branding
|
||||
### 🎨 Customization and Branding
|
||||
|
||||
- Customizable status page
|
||||
- Badge generation for status and uptime of Monitors
|
||||
- Support for custom domains
|
||||
- Embed Monitor as an iframe or widget
|
||||
- Light + Dark Theme
|
||||
- Internationalization support
|
||||
- Beautifully Crafted Status Page
|
||||
- Fully **customizable status page**
|
||||
- **Badge generation** for status and uptime tracking
|
||||
- Support for **custom domains**
|
||||
- Embed monitors as **iframes or widgets**
|
||||
- **Light & Dark Mode**
|
||||
- **Internationalization (i18n) support**
|
||||
- **Sleek, beautifully crafted UI**
|
||||
|
||||
### Incident Management
|
||||
### 🚨 Incident Management
|
||||
|
||||
- Incident Management
|
||||
- Incident Communication
|
||||
- Comprehensive APIs for Incident Management
|
||||
- **Incident tracking & communication** tools
|
||||
- **Comprehensive APIs** for incident management
|
||||
|
||||
### User Experience and Design
|
||||
### 🧑💻 User Experience and Design
|
||||
|
||||
- Good Accessibility Score
|
||||
- Easy installation and setup
|
||||
- User-friendly interface
|
||||
- Responsive design for various devices
|
||||
- Auto SEO and Social Media ready
|
||||
- Server Side Rendering
|
||||
- **Accessible & user-friendly interface**
|
||||
- **Quick & easy installation**
|
||||
- **Responsive design** for all devices
|
||||
- **Auto SEO & Social Media ready**
|
||||
- **Server-Side Rendering (SSR) for better performance**
|
||||
|
||||
<div align="left">
|
||||
<img alt="Visitor Stats" src="https://widgetbite.com/stats/rajnandan"/>
|
||||
<img alt="Visitor Stats" src="https://widgetbite.com/stats/rajnandan"/>
|
||||
</div>
|
||||
|
||||
## Technologies used
|
||||
## Technologies Used
|
||||
|
||||
- [SvelteKit](https://kit.svelte.dev/)
|
||||
- [shadcn-svelte](https://www.shadcn-svelte.com/)
|
||||
|
||||
## Support Me
|
||||
|
||||
If you are using Kener and want to support me, you can do so by sponsoring me on GitHub or buying me a coffee.
|
||||
If you’re enjoying Kener and want to support its development, consider sponsoring me on GitHub or treating me to a coffee. Your support helps keep the project growing! 🚀
|
||||
|
||||
[Sponsor Me Using Github](https://github.com/sponsors/rajnandan1)
|
||||
|
||||
[Buy Me a Coffee](https://www.buymeacoffee.com/rajnandan1)
|
||||
☕ [Buy Me a Coffee](https://www.buymeacoffee.com/rajnandan1)
|
||||
|
||||

|
||||
|
||||
## Contributing
|
||||
|
||||
If you want to contribute to Kener, please read the [Contributing Guide](https://github.com/rajnandan1/kener/blob/main/.github/CONTRIBUTING.md).
|
||||
If you want to contribute to Kener, please read the [Contribution Guide](https://github.com/rajnandan1/kener/blob/main/.github/CONTRIBUTING.md).
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
# Kener - Stunning Status Pages
|
||||
|
||||
<p align="center">
|
||||
<img src="https://kener.ing/newbg.png?v=1" width="100%" height="auto" class="rounded-lg shadow-lg" alt="kener example illustration">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/rajnandan1/kener?label=Star%20Repo&style=social">
|
||||
<a href="https://github.com/ivbeg/awesome-status-pages"><img src="https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg" alt="Awesome status page" /></a>
|
||||
<a href="https://awesome-selfhosted.net/tags/status--uptime-pages.html#kener"><img src="https://awesome.re/mentioned-badge.svg" alt="Awesome self hosted" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://hub.docker.com/r/rajnandan1/kener"><img src="https://img.shields.io/docker/pulls/rajnandan1/kener" alt="Docker Kener" /></a>
|
||||
<a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=latest"><img alt="Docker Image Size" src="https://img.shields.io/docker/image-size/rajnandan1/kener/latest?logo=docker&logoColor=white&label=debian" /></a>
|
||||
<a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=alpine"><img alt="Docker Image Size" src="https://img.shields.io/docker/image-size/rajnandan1/kener/alpine?logo=docker&logoColor=white&label=alpine" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/rajnandan1/kener/actions/workflows/publish-images.yml"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/rajnandan1/kener/publish-images.yml" /></a>
|
||||
<a href="https://github.com/rajnandan1/kener/commit/HEAD"><img src="https://img.shields.io/github/last-commit/rajnandan1/kener/main" alt="" /></a>
|
||||
<a href="https://github.com/rajnandan1/kener/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/rajnandan1/kener.svg" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.producthunt.com/posts/kener-2" target="_blank">
|
||||
<img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=kener-2&theme=light" alt="Kener on Product Hunt">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f514/512.webp" type="image/webp">
|
||||
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f514/512.gif" alt="🔔" width="32" height="32">
|
||||
</picture>
|
||||
<picture>
|
||||
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f680/512.webp" type="image/webp">
|
||||
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f680/512.gif" alt="🚀" width="32" height="32">
|
||||
</picture>
|
||||
<picture>
|
||||
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f6a7/512.webp" type="image/webp">
|
||||
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f6a7/512.gif" alt="🚧" width="32" height="32">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
| [🌍 Live Server](https://kener.ing) | [🎉 Quick Start](https://kener.ing/docs/quick-start) | [🗄 Documentation](https://kener.ing/docs/home) |
|
||||
| ----------------------------------- | ---------------------------------------------------- | ----------------------------------------------- |
|
||||
|
||||
## What is Kener?
|
||||
|
||||
**Kener** is a sleek and lightweight status page system built with **SvelteKit** and **NodeJS**. It’s not here to replace heavyweights like Datadog or Atlassian but rather to offer a simple, modern, and hassle-free way to set up a great-looking status page with minimal effort.
|
||||
|
||||
Designed with **ease of use** and **customization in mind**, Kener provides all the essential features you’d expect from a status page—without unnecessary complexity.
|
||||
|
||||
### Why Kener?
|
||||
|
||||
✅ Minimal overhead – Set up quickly with a clean, modern UI<br>
|
||||
✅ Customizable – Easily tailor it to match your brand<br>
|
||||
✅ Open-source & free – Because great tools should be accessible to everyone
|
||||
|
||||
### What's in a Name?
|
||||
|
||||
“Kener” is inspired by the Assamese word _“Kene”_, meaning _“how’s it going?”_. The _‘.ing’_ was added because, well… that domain was available. 😄
|
||||
|
||||
## Installation
|
||||
|
||||
### Manual
|
||||
|
||||
```shell
|
||||
# Clone the repository
|
||||
git clone https://github.com/rajnandan1/kener.git
|
||||
cd kener
|
||||
npm install
|
||||
cp .env.example .env
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
Official Docker images for **Kener** are available on [Docker Hub](https://hub.docker.com/r/rajnandan1/kener). Multiple versions are maintained to support different use cases.
|
||||
|
||||
<a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name={{kener_full_version}}"><img src="https://img.shields.io/badge/Latest_Stable_Release-{{kener_full_version}}-blue" alt="Kener latest stable version: {{kener_full_version}}" /></a>
|
||||
|
||||
#### Available Tags
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Image Tag</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" colspan="2" style="color:#A81D33;text-align:left;">Debian 12 <small>(Bookwork Slim)</small> w/ Node.js v23.7.0 <strong><em>(default)</em></strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=latest" target="_blank"><code>latest</code></td>
|
||||
<td>Latest stable release (aka {{kener_full_version}})</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name={{kener_full_version}}" target="_blank"><code>{{kener_full_version}}</code></a></td>
|
||||
<td>Specific release version</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name={{kener_major_minor_version}}" target="_blank"><code>{{kener_major_minor_version}}</code></a></td>
|
||||
<td>Major-minor version tag pointing to the latest patch ({{kener_full_version}}) release within that minor version ({{kener_major_minor_version}}.x)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name={{kener_major_version}}" target="_blank"><code>{{kener_major_version}}</code></a></td>
|
||||
<td>Major version tag pointing to the latest stable ({{kener_full_version}}) release within that major version ({{kener_major_version}}.x.x)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" colspan="2" style="color:#0D597F;text-align:left;">Alpine Linux 3.21 w/ Node.js v23.7.0 <strong><em>(smallest image size)</em></strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=alpine" target="_blank"><code>alpine</code></td>
|
||||
<td>Latest stable release (aka {{kener_full_version}})</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name={{kener_full_version}}-alpine" target="_blank"><code>{{kener_full_version}}-alpine</code></a></td>
|
||||
<td>Specific release version</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name={{kener_major_minor_version}}-alpine" target="_blank"><code>{{kener_major_minor_version}}-alpine</code></a></td>
|
||||
<td>Major-minor version tag pointing to the latest patch ({{kener_full_version}}) release within that minor version ({{kener_major_minor_version}}.x)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name={{kener_major_version}}-alpine" target="_blank"><code>{{kener_major_version}}-alpine</code></a></td>
|
||||
<td>Major version tag pointing to the latest stable ({{kener_full_version}}) release within that major version ({{kener_major_version}}.x.x)</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
#### Usage
|
||||
|
||||
Pull the latest stable version:
|
||||
|
||||
```sh
|
||||
docker pull rajnandan1/kener:latest
|
||||
```
|
||||
|
||||
Or use the smaller, Alpine-based variant:
|
||||
|
||||
```sh
|
||||
docker pull rajnandan1/kener:alpine
|
||||
```
|
||||
|
||||
For a production setup, refer to the sample [docker-compose.yml](https://github.com/rajnandan1/kener/blob/main/docker-compose.yml).
|
||||
This keeps things clean, structured, and easy to read while preserving all the details.
|
||||
|
||||
### One Click
|
||||
|
||||
[](https://railway.com/template/spSvic?referralCode=1Pn7vs)
|
||||
|
||||
## Features
|
||||
|
||||
Here are some of the features that you get out of the box. Please read the documentation to know how to use them.
|
||||
|
||||
### 📊 Monitoring and Tracking
|
||||
|
||||
- Advanced **application performance monitoring** tools
|
||||
- **Real-time network monitoring** capabilities
|
||||
- Supports **polling HTTP endpoints** or **pushing data** via REST APIs
|
||||
- **Timezone auto-adjustment** for visitors
|
||||
- Organize monitors into **custom sections**
|
||||
- **Cron-based scheduling** (minimum: **every minute**)
|
||||
- **Create complex API polls** (chaining, secrets, etc.)
|
||||
- Set a **default status** for monitors
|
||||
- Supports **base path hosting in Kubernetes (k8s)**
|
||||
- **Pre-built Docker images** for easy deployment
|
||||
|
||||
### 🎨 Customization and Branding
|
||||
|
||||
- Fully **customizable status page**
|
||||
- **Badge generation** for status and uptime tracking
|
||||
- Support for **custom domains**
|
||||
- Embed monitors as **iframes or widgets**
|
||||
- **Light & Dark Mode**
|
||||
- **Internationalization (i18n) support**
|
||||
- **Sleek, beautifully crafted UI**
|
||||
|
||||
### 🚨 Incident Management
|
||||
|
||||
- **Incident tracking & communication** tools
|
||||
- **Comprehensive APIs** for incident management
|
||||
|
||||
### 🧑💻 User Experience and Design
|
||||
|
||||
- **Accessible & user-friendly interface**
|
||||
- **Quick & easy installation**
|
||||
- **Responsive design** for all devices
|
||||
- **Auto SEO & Social Media ready**
|
||||
- **Server-Side Rendering (SSR) for better performance**
|
||||
|
||||
<div align="left">
|
||||
<img alt="Visitor Stats" src="https://widgetbite.com/stats/rajnandan"/>
|
||||
</div>
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- [SvelteKit](https://kit.svelte.dev/)
|
||||
- [shadcn-svelte](https://www.shadcn-svelte.com/)
|
||||
|
||||
## Support Me
|
||||
|
||||
If you’re enjoying Kener and want to support its development, consider sponsoring me on GitHub or treating me to a coffee. Your support helps keep the project growing! 🚀
|
||||
|
||||
[Sponsor Me Using Github](https://github.com/sponsors/rajnandan1)
|
||||
|
||||
☕ [Buy Me a Coffee](https://www.buymeacoffee.com/rajnandan1)
|
||||
|
||||

|
||||
|
||||
## Contributing
|
||||
|
||||
If you want to contribute to Kener, please read the [Contribution Guide](https://github.com/rajnandan1/kener/blob/main/.github/CONTRIBUTING.md).
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#rajnandan1/kener&Date)
|
||||
@@ -1,26 +1,61 @@
|
||||
# Docker Compose Configuration
|
||||
# Description: This file sets up a multi-container environment for Kener (https://github.com/rajnandan1/kener).
|
||||
# Last Updated: 2025-02-08
|
||||
# Docker Compose Version: 3.8
|
||||
# Notes: Ensure that you specify a random value for the `KENER_SECRET_KEY` environment variable before running `docker-compose up -d`.
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
kener:
|
||||
image: rajnandan1/kener:latest
|
||||
image: rajnandan1/kener:latest # Change to 'rajnandan1/kener:alpine' for an even smaller image! 😁🚀
|
||||
container_name: kener
|
||||
#env_file: .env #uncomment this, if you are using .env file
|
||||
# env_file: custom.env # Uncomment this if you are needing to export environment variables from a custom environment file. By default, Docker will import any variables that exist in `.env`
|
||||
environment:
|
||||
- TZ=Etc/UTC
|
||||
- NODE_ENV=production
|
||||
#- PORT=3000
|
||||
#- KENER_BASE_PATH=
|
||||
#- RESEND_API_KEY=
|
||||
#- ORIGIN=
|
||||
#- KENER_SECRET_KEY=
|
||||
#- RESEND_SENDER_EMAIL=
|
||||
#- DATABASE_URL=
|
||||
TZ: Etc/UTC
|
||||
KENER_SECRET_KEY: replace_me_with_a_random_string # Keep private!! - best to define in `.env` file or through Docker Secret
|
||||
# DATABASE_URL: custom_db_url # By default, a SQLite database is used - you may override the database url/type here
|
||||
# RESEND_API_KEY:
|
||||
# RESEND_SENDER_EMAIL:
|
||||
|
||||
### You most likely will NOT need to change anything below this line. Be sure you know what you're doing!! (https://kener.ing/docs/deployment/#docker-environment-variables)
|
||||
|
||||
### Most likely DO NOT need to change anything below this ###
|
||||
|
||||
#- PORT=3000 Port app listens on IN CONTAINER
|
||||
# PORT: 3000 # Port that app listens on in the container
|
||||
# KENER_BASE_PATH: # By default, Kener runs at `/`. You may change this to be, e.g. `/status`, etc. Do NOT add a trailing slash!! (more info here: https://kener.ing/docs/deployment/#docker-environment-variables)
|
||||
# ORIGIN: http://localhost:3000
|
||||
# NODE_ENV: production # This is already set to "production" by default within the container
|
||||
ports:
|
||||
- '3000:3000/tcp'
|
||||
volumes:
|
||||
- '.:/app/database:rw'
|
||||
- '.:/app/uploads:rw'
|
||||
- data:/app/database # We suggest using a Docker named volume, which is more performant for databases
|
||||
- $(pwd)/uploads:/app/uploads
|
||||
# read_only: true # Uncommenting this fortifies security by marking the container's filesystem as read-only (aka no data can be written to the container's filesystem except for explicitly defined writable volumes and bind mounts, an exception has already been defined for `/database` and `/uploads`)
|
||||
restart: unless-stopped
|
||||
# depends_on: # <-- Uncomment if you would like to use PostgreSQL or MySQL
|
||||
# - postgres # ...instead of SQLite
|
||||
# - mysql #
|
||||
|
||||
# Only use below section if you would like to utilize PostgreSQL instead of Kener's default SQLite database. (Don't forget to set `DATABASE_URL` in `kener` service to be: `DATABASE_URL=postgresql://db_user:db_password@localhost:5432/kener_db`)
|
||||
postgres:
|
||||
image: postgres:alpine
|
||||
name: kener_db
|
||||
environment:
|
||||
POSTGRES_USER: user
|
||||
POSTGRES_PASSWORD: some_super_random_secure_password # Best to define this in `.env` or via Docker Secret!!
|
||||
POSTGRES_DB: kener_db
|
||||
restart: unless-stopped
|
||||
|
||||
# Only use below section if you would like to utilize MySQL instead of Kener's default SQLite database. (Don't forget to set `DATABASE_URL` in `kener` service to be: `DATABASE_URL=mysql://db_user:db_password@localhost:3306/kener_db`)
|
||||
mysql:
|
||||
image: mariadb:11
|
||||
name: kener_db
|
||||
environment:
|
||||
MYSQL_USER: user
|
||||
MYSQL_PASSWORD: some_super_random_secure_password # Best to define this in `.env` or via Docker Secret!!
|
||||
MYSQL_DATABASE: kener_db
|
||||
MYSQL_RANDOM_ROOT_PASSWORD: true
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
data:
|
||||
name: kener_db
|
||||
@@ -64,24 +64,24 @@ Body of the webhook will be sent as below:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "mockoon-9",
|
||||
"alert_name": "Mockoon DOWN",
|
||||
"severity": "critical",
|
||||
"status": "TRIGGERED",
|
||||
"source": "Kener",
|
||||
"timestamp": "2024-11-27T04:55:00.369Z",
|
||||
"description": "🚨 **Service Alert**: Check the details below",
|
||||
"details": {
|
||||
"metric": "Mockoon",
|
||||
"current_value": 1,
|
||||
"threshold": 1
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"text": "View Monitor",
|
||||
"url": "https://kener.ing/monitor-mockoon"
|
||||
}
|
||||
]
|
||||
"id": "mockoon-9",
|
||||
"alert_name": "Mockoon DOWN",
|
||||
"severity": "critical",
|
||||
"status": "TRIGGERED",
|
||||
"source": "Kener",
|
||||
"timestamp": "2024-11-27T04:55:00.369Z",
|
||||
"description": "🚨 **Service Alert**: Check the details below",
|
||||
"details": {
|
||||
"metric": "Mockoon",
|
||||
"current_value": 1,
|
||||
"threshold": 1
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"text": "View Monitor",
|
||||
"url": "https://kener.ing/monitor-mockoon"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -108,7 +108,7 @@ The discord message when alert is `TRIGGERED` will look like this
|
||||
|
||||
The discord message when alert is `RESOLVED` will look like this
|
||||
|
||||

|
||||

|
||||
|
||||
### Slack
|
||||
|
||||
@@ -118,7 +118,7 @@ The slack message when alert is `TRIGGERED` will look like this
|
||||
|
||||
The slack message when alert is `RESOLVED` will look like this
|
||||
|
||||

|
||||

|
||||
|
||||
### Add Alerts to Monitors
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ ghcr.io/rajnandan1/kener:latest
|
||||
|
||||
[Environment variables](/docs/environment-vars) can be passed with `-e` An example `docker run` command:
|
||||
|
||||
Make sure `./database` directory is present in the root directory.
|
||||
Make sure `./database` and `./uploads` directories are present in the root directory.
|
||||
|
||||
### Examples
|
||||
|
||||
@@ -65,7 +65,10 @@ This example is for sqlite. You can also use postgres. Read more about it [here]
|
||||
#### sqlite
|
||||
|
||||
```bash
|
||||
mkdir database
|
||||
mkdir database uploads
|
||||
```
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-v $(pwd)/database:/app/database \
|
||||
-v $(pwd)/uploads:/app/uploads \
|
||||
@@ -79,6 +82,10 @@ docker run \
|
||||
|
||||
You can also use a .env file
|
||||
|
||||
```bash
|
||||
mkdir database uploads
|
||||
```
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-v $(pwd)/database:/app/database \
|
||||
@@ -92,6 +99,12 @@ Or use **Docker Compose** with the example [docker-compose.yaml](https://raw.git
|
||||
|
||||
#### Postgres
|
||||
|
||||
```bash
|
||||
mkdir uploads
|
||||
```
|
||||
|
||||
Database folder is not required for postgres.
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-p 3000:3000 \
|
||||
@@ -104,6 +117,12 @@ docker run \
|
||||
|
||||
#### MySQL
|
||||
|
||||
```bash
|
||||
mkdir uploads
|
||||
```
|
||||
|
||||
Database folder is not required for mysql.
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-p 3000:3000 \
|
||||
@@ -122,6 +141,7 @@ By default kener runs on `/` but you can change it to `/status` or any other pat
|
||||
|
||||
- Important: The base path should _**NOT**_ have a trailing slash and should start with `/`
|
||||
- Important: This env variable should be present during both build and run time
|
||||
- Important: You will have to build the frontend with the same base path
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -93,10 +93,24 @@ export SMTP_SECURE=0
|
||||
export SMTP_FROM_EMAIL=Some Name <user@example.com>
|
||||
```
|
||||
|
||||
<div class=" note info ">
|
||||
|
||||
Generally, port 465 expects implicit SSL (SMTP_SECURE: 1), while port 587 and port 25 usually use STARTTLS (SMTP_SECURE: 0).
|
||||
|
||||
So, if you are using SMTP on port 465, set SMTP_SECURE: 1; otherwise, for ports 25 or 587, set SMTP_SECURE: 0.
|
||||
|
||||
</div>
|
||||
|
||||
<div class=" note danger ">
|
||||
If you set SMTP variables, Kener will use SMTP to send emails. If you set RESEND_API_KEY, Kener will use resend to send emails. If you do both Kener will use SMTP.
|
||||
</div>
|
||||
|
||||
<div class=" note info ">
|
||||
|
||||
If your SMTP provider does require username and password, you can set `SMTP_USER` and `SMTP_PASS` to `-`.
|
||||
|
||||
</div>
|
||||
|
||||
## Using .env
|
||||
|
||||
You can also use a `.env` file to set these variables. Create a `.env` file in the root of the project and add the variables like below
|
||||
|
||||
@@ -21,7 +21,7 @@ A small text that will be shown below the title.
|
||||
|
||||
<div class="border rounded-md">
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ A small text that will be shown below the title.
|
||||
|
||||
You can navigation links to other urls. You can add as many as you want.
|
||||
|
||||

|
||||

|
||||
|
||||
### Icon
|
||||
|
||||
@@ -45,7 +45,7 @@ The title of the link.
|
||||
|
||||
The URL to redirect to when the link is clicked.
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ API monitors are used to monitor APIs. You can use API monitors to monitor the u
|
||||
|
||||
<div class="border rounded-md">
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@ This is an anonymous JS function, it should return a **Promise**, that resolves
|
||||
- `responseDataBase64` **REQUIRED** is a string. It is the base64 encoded response data. To use it you will have to decode it
|
||||
|
||||
```js
|
||||
let decodedResp = atob(responseDataBase64);
|
||||
let decodedResp = atob(responseDataBase64)
|
||||
//if the response is a json object
|
||||
//let jsonResp = JSON.parse(decodedResp)
|
||||
```
|
||||
@@ -79,61 +79,61 @@ let decodedResp = atob(responseDataBase64);
|
||||
The following example shows how to use the eval function to evaluate the response. The function checks if the status code is 2XX then the status is UP, if the status code is 5XX then the status is DOWN. If the response contains the word `Unknown Error` then the status is DOWN. If the response time is greater than 2000 then the status is DEGRADED.
|
||||
|
||||
```javascript
|
||||
(async function (statusCode, responseTime, responseDataBase64) {
|
||||
const resp = atob(responseDataBase64); //convert base64 to string
|
||||
;(async function (statusCode, responseTime, responseDataBase64) {
|
||||
const resp = atob(responseDataBase64) //convert base64 to string
|
||||
|
||||
let status = "DOWN";
|
||||
let status = "DOWN"
|
||||
|
||||
//if the status code is 2XX then the status is UP
|
||||
if (/^[2]\d{2}$/.test(statusCode)) {
|
||||
status = "UP";
|
||||
if (responseTime > 2000) {
|
||||
status = "DEGRADED";
|
||||
}
|
||||
}
|
||||
//if the status code is 2XX then the status is UP
|
||||
if (/^[2]\d{2}$/.test(statusCode)) {
|
||||
status = "UP"
|
||||
if (responseTime > 2000) {
|
||||
status = "DEGRADED"
|
||||
}
|
||||
}
|
||||
|
||||
//if the status code is 5XX then the status is DOWN
|
||||
if (/^[5]\d{2}$/.test(statusCode)) status = "DOWN";
|
||||
//if the status code is 5XX then the status is DOWN
|
||||
if (/^[5]\d{2}$/.test(statusCode)) status = "DOWN"
|
||||
|
||||
if (resp.includes("Unknown Error")) {
|
||||
status = "DOWN";
|
||||
}
|
||||
if (resp.includes("Unknown Error")) {
|
||||
status = "DOWN"
|
||||
}
|
||||
|
||||
return {
|
||||
status: status,
|
||||
latency: responseTime
|
||||
};
|
||||
});
|
||||
return {
|
||||
status: status,
|
||||
latency: responseTime
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
This next example shows how to call another API withing eval. It is scrapping the second last script tag from the response and checking if the heading is "No recent issues" then the status is UP else it is DOWN.
|
||||
|
||||
```javascript
|
||||
(async function raj(statusCode, responseTime, responseDataBase64) {
|
||||
let htmlString = atob(responseDataBase64);
|
||||
const scriptTags = htmlString.match(/<script[^>]*src="([^"]+)"[^>]*>/g);
|
||||
if (scriptTags && scriptTags.length >= 2) {
|
||||
// Extract the second last script tag's src attribute
|
||||
const secondLastScript = scriptTags[scriptTags.length - 2];
|
||||
const srcMatch = secondLastScript.match(/src="([^"]+)"/);
|
||||
const secondLastScriptSrc = srcMatch ? srcMatch[1] : null;
|
||||
;(async function raj(statusCode, responseTime, responseDataBase64) {
|
||||
let htmlString = atob(responseDataBase64)
|
||||
const scriptTags = htmlString.match(/<script[^>]*src="([^"]+)"[^>]*>/g)
|
||||
if (scriptTags && scriptTags.length >= 2) {
|
||||
// Extract the second last script tag's src attribute
|
||||
const secondLastScript = scriptTags[scriptTags.length - 2]
|
||||
const srcMatch = secondLastScript.match(/src="([^"]+)"/)
|
||||
const secondLastScriptSrc = srcMatch ? srcMatch[1] : null
|
||||
|
||||
let jsResp = await fetch(secondLastScriptSrc); //api call
|
||||
let jsRespText = await jsResp.text();
|
||||
//check if heading":"No recent issues" exists
|
||||
let noRecentIssues = jsRespText.indexOf('heading":"No recent issues"');
|
||||
if (noRecentIssues != -1) {
|
||||
return {
|
||||
status: "UP",
|
||||
latency: responseTime
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: "DOWN",
|
||||
latency: responseTime
|
||||
};
|
||||
});
|
||||
let jsResp = await fetch(secondLastScriptSrc) //api call
|
||||
let jsRespText = await jsResp.text()
|
||||
//check if heading":"No recent issues" exists
|
||||
let noRecentIssues = jsRespText.indexOf('heading":"No recent issues"')
|
||||
if (noRecentIssues != -1) {
|
||||
return {
|
||||
status: "UP",
|
||||
latency: responseTime
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: "DOWN",
|
||||
latency: responseTime
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Examples
|
||||
@@ -153,7 +153,7 @@ This is an example to monitor google every 5 minute.
|
||||
|
||||
<div class="border rounded-md">
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -180,7 +180,7 @@ export SOME_TOKEN=some-token-example
|
||||
|
||||
<div class="border rounded-md p-1">
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -201,7 +201,7 @@ Example showing setting up a POST request every minute with a timeout of 2 secon
|
||||
|
||||
<div class="border rounded-md p-1">
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -228,7 +228,7 @@ export SERVICE_SECRET=secret2_secret
|
||||
|
||||
<div class="border rounded-md p-1">
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ DNS monitors are used to monitor DNS servers. Verify DNS queries for your server
|
||||
|
||||
<div class="border rounded-md">
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: Group Monitors | Kener
|
||||
description: Learn how to set up and work with Group monitors in kener.
|
||||
---
|
||||
|
||||
# Group Monitors
|
||||
|
||||
Group monitors are used to monitor multiple monitors at once. You can use Group monitors to monitor multiple monitors at once and get notified when they are down.
|
||||
|
||||
<div class="border rounded-md">
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
## Timeout
|
||||
|
||||
<span class="text-red-500 text-xs font-semibold">
|
||||
REQUIRED
|
||||
</span>
|
||||
|
||||
The timeout is used to define the time in milliseconds after which the group monitor should timeout.
|
||||
|
||||
Let us say the group monitor runs every minute, it will expect in the same minute all the other monitors to finish. It will wait till the timeout for them to complete. If not completed within that timeout, it will be marked as down.
|
||||
|
||||
## Monitors
|
||||
|
||||
<span class="text-red-500 text-xs font-semibold">
|
||||
REQUIRED
|
||||
</span>
|
||||
|
||||
You can add as many monitors as you want to monitor. The minimum number of monitors required is 2. The monitor can be any type of monitor.
|
||||
|
||||
## Hide
|
||||
|
||||
You can hide the monitors that are part of the group monitor. If you hide the monitors, the monitors inside the group will not be shown in the home page.
|
||||
|
||||
<div class="note">
|
||||
The group status will be the worst status of the monitors in the group.
|
||||
</div>
|
||||
@@ -9,7 +9,7 @@ Ping monitors are used to monitor livenees of your servers. You can use Ping mon
|
||||
|
||||
<div class="border rounded-md">
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -32,29 +32,29 @@ This is an anonymous JS function, it should return a **Promise**, that resolves
|
||||
> `{status:"DEGRADED", latency: 200}`.
|
||||
|
||||
```javascript
|
||||
(async function (responseDataBase64) {
|
||||
let arrayOfPings = JSON.parse(atob(responseDataBase64));
|
||||
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
|
||||
return acc + ping.latency;
|
||||
}, 0);
|
||||
;(async function (responseDataBase64) {
|
||||
let arrayOfPings = JSON.parse(atob(responseDataBase64))
|
||||
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
|
||||
return acc + ping.latency
|
||||
}, 0)
|
||||
|
||||
let alive = arrayOfPings.reduce((acc, ping) => {
|
||||
return acc && ping.alive;
|
||||
}, true);
|
||||
let alive = arrayOfPings.reduce((acc, ping) => {
|
||||
return acc && ping.alive
|
||||
}, true)
|
||||
|
||||
return {
|
||||
status: alive ? "UP" : "DOWN",
|
||||
latency: latencyTotal / arrayOfPings.length
|
||||
};
|
||||
});
|
||||
return {
|
||||
status: alive ? "UP" : "DOWN",
|
||||
latency: latencyTotal / arrayOfPings.length
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
- `responseDataBase64` **REQUIRED** is a string. It is the base64 encoded response data. To use it you will have to decode it and the JSON parse it. Once parse it will be an array of objects.
|
||||
|
||||
```js
|
||||
let decodedResp = atob(responseDataBase64);
|
||||
let jsonResp = JSON.parse(decodedResp);
|
||||
console.log(jsonResp);
|
||||
let decodedResp = atob(responseDataBase64)
|
||||
let jsonResp = JSON.parse(decodedResp)
|
||||
console.log(jsonResp)
|
||||
/*
|
||||
[
|
||||
{
|
||||
@@ -109,28 +109,28 @@ The input to the eval function is a base64 encoded string. You will have to deco
|
||||
The following example shows how to use the eval function to evaluate the response. The function checks if the combined latency is more 10ms then returns `DEGRADED`.
|
||||
|
||||
```javascript
|
||||
(async function (responseDataBase64) {
|
||||
let arrayOfPings = JSON.parse(atob(responseDataBase64));
|
||||
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
|
||||
return acc + ping.latency;
|
||||
}, 0);
|
||||
;(async function (responseDataBase64) {
|
||||
let arrayOfPings = JSON.parse(atob(responseDataBase64))
|
||||
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
|
||||
return acc + ping.latency
|
||||
}, 0)
|
||||
|
||||
let areAllOpen = arrayOfPings.reduce((acc, ping) => {
|
||||
return acc && ping.alive;
|
||||
}, true);
|
||||
let areAllOpen = arrayOfPings.reduce((acc, ping) => {
|
||||
return acc && ping.alive
|
||||
}, true)
|
||||
|
||||
let avgLatency = latencyTotal / arrayOfPings.length;
|
||||
let avgLatency = latencyTotal / arrayOfPings.length
|
||||
|
||||
if (areAllOpen && avgLatency > 10) {
|
||||
return {
|
||||
status: "DEGRADED",
|
||||
latency: avgLatency
|
||||
};
|
||||
}
|
||||
if (areAllOpen && avgLatency > 10) {
|
||||
return {
|
||||
status: "DEGRADED",
|
||||
latency: avgLatency
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: areAllOpen ? "UP" : "DOWN",
|
||||
latency: avgLatency
|
||||
};
|
||||
});
|
||||
return {
|
||||
status: areAllOpen ? "UP" : "DOWN",
|
||||
latency: avgLatency
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
@@ -9,7 +9,7 @@ TCP monitors are used to monitor the livenees of your servers. You can use TCP m
|
||||
|
||||
<div class="border rounded-md">
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -32,33 +32,33 @@ This is an anonymous JS function, it should return a **Promise**, that resolves
|
||||
> `{status:"DEGRADED", latency: 200}`.
|
||||
|
||||
```javascript
|
||||
(async function (responseDataBase64) {
|
||||
let arrayOfPings = JSON.parse(atob(responseDataBase64));
|
||||
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
|
||||
return acc + ping.latency;
|
||||
}, 0);
|
||||
;(async function (responseDataBase64) {
|
||||
let arrayOfPings = JSON.parse(atob(responseDataBase64))
|
||||
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
|
||||
return acc + ping.latency
|
||||
}, 0)
|
||||
|
||||
let alive = arrayOfPings.reduce((acc, ping) => {
|
||||
if (ping.status === "open") {
|
||||
return acc && true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}, true);
|
||||
let alive = arrayOfPings.reduce((acc, ping) => {
|
||||
if (ping.status === "open") {
|
||||
return acc && true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}, true)
|
||||
|
||||
return {
|
||||
status: alive ? "UP" : "DOWN",
|
||||
latency: latencyTotal / arrayOfPings.length
|
||||
};
|
||||
});
|
||||
return {
|
||||
status: alive ? "UP" : "DOWN",
|
||||
latency: latencyTotal / arrayOfPings.length
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
- `responseDataBase64` **REQUIRED** is a string. It is the base64 encoded response data. To use it you will have to decode it and the JSON parse it. Once parse it will be an array of objects.
|
||||
|
||||
```js
|
||||
let decodedResp = atob(responseDataBase64);
|
||||
let jsonResp = JSON.parse(decodedResp);
|
||||
console.log(jsonResp);
|
||||
let decodedResp = atob(responseDataBase64)
|
||||
let jsonResp = JSON.parse(decodedResp)
|
||||
console.log(jsonResp)
|
||||
/*
|
||||
[
|
||||
{
|
||||
@@ -104,32 +104,32 @@ The input to the eval function is a base64 encoded string. You will have to deco
|
||||
The following example shows how to use the eval function to evaluate the response. The function checks if the combined latency is more 10ms then returns `DEGRADED`.
|
||||
|
||||
```javascript
|
||||
(async function (responseDataBase64) {
|
||||
let arrayOfPings = JSON.parse(atob(responseDataBase64));
|
||||
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
|
||||
return acc + ping.latency;
|
||||
}, 0);
|
||||
;(async function (responseDataBase64) {
|
||||
let arrayOfPings = JSON.parse(atob(responseDataBase64))
|
||||
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
|
||||
return acc + ping.latency
|
||||
}, 0)
|
||||
|
||||
let areAllOpen = arrayOfPings.reduce((acc, ping) => {
|
||||
if (ping.status === "open") {
|
||||
return acc && true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}, true);
|
||||
let areAllOpen = arrayOfPings.reduce((acc, ping) => {
|
||||
if (ping.status === "open") {
|
||||
return acc && true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}, true)
|
||||
|
||||
let avgLatency = latencyTotal / arrayOfPings.length;
|
||||
let avgLatency = latencyTotal / arrayOfPings.length
|
||||
|
||||
if (areAllOpen && avgLatency > 10) {
|
||||
return {
|
||||
status: "DEGRADED",
|
||||
latency: avgLatency
|
||||
};
|
||||
}
|
||||
if (areAllOpen && avgLatency > 10) {
|
||||
return {
|
||||
status: "DEGRADED",
|
||||
latency: avgLatency
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: areAllOpen ? "UP" : "DOWN",
|
||||
latency: avgLatency
|
||||
};
|
||||
});
|
||||
return {
|
||||
status: areAllOpen ? "UP" : "DOWN",
|
||||
latency: avgLatency
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
@@ -13,7 +13,7 @@ Click on the ➕ to add a monitor.
|
||||
|
||||
<div class="border rounded-md">
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,11 +19,11 @@ Example: `Kener - Open-Source and Modern looking Node.js Status Page for Effortl
|
||||
|
||||
```html
|
||||
<title>
|
||||
Kener - Open-Source and Modern looking Node.js Status Page for Effortless Incident Management
|
||||
Kener - Open-Source and Modern looking Node.js Status Page for Effortless Incident Management
|
||||
</title>
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
## Site Name
|
||||
|
||||
@@ -33,7 +33,7 @@ Example: `Kener - Open-Source and Modern looking Node.js Status Page for Effortl
|
||||
|
||||
This will be shown as a brand name on the status page on the nav bar top left.
|
||||
|
||||

|
||||

|
||||
|
||||
## Home Location
|
||||
|
||||
|
||||
@@ -1,166 +1,166 @@
|
||||
{
|
||||
"sidebar": [
|
||||
{
|
||||
"sectionTitle": "Getting Started",
|
||||
"children": [
|
||||
{
|
||||
"title": "Introduction",
|
||||
"link": "/docs/home",
|
||||
"file": "/home.md"
|
||||
},
|
||||
{
|
||||
"title": "Get Started",
|
||||
"link": "/docs/quick-start",
|
||||
"file": "/quick-start.md"
|
||||
},
|
||||
{
|
||||
"title": "Concepts",
|
||||
"link": "/docs/concepts",
|
||||
"file": "/concepts.md"
|
||||
},
|
||||
"sidebar": [
|
||||
{
|
||||
"sectionTitle": "Getting Started",
|
||||
"children": [
|
||||
{
|
||||
"title": "Introduction",
|
||||
"link": "/docs/home",
|
||||
"file": "/home.md"
|
||||
},
|
||||
{
|
||||
"title": "Get Started",
|
||||
"link": "/docs/quick-start",
|
||||
"file": "/quick-start.md"
|
||||
},
|
||||
{
|
||||
"title": "Concepts",
|
||||
"link": "/docs/concepts",
|
||||
"file": "/concepts.md"
|
||||
},
|
||||
|
||||
{
|
||||
"title": "Deployment",
|
||||
"link": "/docs/deployment",
|
||||
"file": "/deployment.md"
|
||||
},
|
||||
{
|
||||
"title": "Databases",
|
||||
"link": "/docs/database",
|
||||
"file": "/database.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sectionTitle": "Guides",
|
||||
"children": [
|
||||
{
|
||||
"title": "Setup Environment",
|
||||
"link": "/docs/environment-vars",
|
||||
"file": "/environment-vars.md"
|
||||
},
|
||||
{
|
||||
"title": "Use Badges",
|
||||
"link": "/docs/status-badges",
|
||||
"file": "/status-badges.md"
|
||||
},
|
||||
{
|
||||
"title": "Setup Monitors",
|
||||
"link": "/docs/monitors",
|
||||
"file": "/monitors.md"
|
||||
},
|
||||
{
|
||||
"title": "API/Website Monitor",
|
||||
"link": "/docs/monitors-api",
|
||||
"file": "/monitors-api.md"
|
||||
},
|
||||
{
|
||||
"title": "Ping Monitor",
|
||||
"link": "/docs/monitors-ping",
|
||||
"file": "/monitors-ping.md"
|
||||
},
|
||||
{
|
||||
"title": "TCP Monitor",
|
||||
"link": "/docs/monitors-tcp",
|
||||
"file": "/monitors-tcp.md"
|
||||
},
|
||||
{
|
||||
"title": "DNS Monitor",
|
||||
"link": "/docs/monitors-dns",
|
||||
"file": "/monitors-dns.md"
|
||||
},
|
||||
{
|
||||
"title": "Setup Triggers",
|
||||
"link": "/docs/triggers",
|
||||
"file": "/triggers.md"
|
||||
},
|
||||
{
|
||||
"title": "Setup Site",
|
||||
"link": "/docs/site",
|
||||
"file": "/site.md"
|
||||
},
|
||||
{
|
||||
"title": "Setup Github",
|
||||
"link": "/docs/gh-setup",
|
||||
"file": "/gh-setup.md"
|
||||
},
|
||||
{
|
||||
"title": "Setup SEO",
|
||||
"link": "/docs/seo",
|
||||
"file": "/seo.md"
|
||||
},
|
||||
{
|
||||
"title": "Setup Home",
|
||||
"link": "/docs/home-page",
|
||||
"file": "/home-page.md"
|
||||
},
|
||||
{
|
||||
"title": "Setup Theme",
|
||||
"link": "/docs/theme",
|
||||
"file": "/theme.md"
|
||||
},
|
||||
{
|
||||
"title": "View Alerts",
|
||||
"link": "/docs/alerts",
|
||||
"file": "/alerts.md"
|
||||
},
|
||||
{
|
||||
"title": "API Keys",
|
||||
"link": "/docs/apikeys",
|
||||
"file": "/apikeys.md"
|
||||
},
|
||||
{
|
||||
"title": "Deployment",
|
||||
"link": "/docs/deployment",
|
||||
"file": "/deployment.md"
|
||||
},
|
||||
{
|
||||
"title": "Databases",
|
||||
"link": "/docs/database",
|
||||
"file": "/database.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sectionTitle": "Guides",
|
||||
"children": [
|
||||
{
|
||||
"title": "Setup Environment",
|
||||
"link": "/docs/environment-vars",
|
||||
"file": "/environment-vars.md"
|
||||
},
|
||||
{
|
||||
"title": "Use Badges",
|
||||
"link": "/docs/status-badges",
|
||||
"file": "/status-badges.md"
|
||||
},
|
||||
{
|
||||
"title": "Setup Monitors",
|
||||
"link": "/docs/monitors",
|
||||
"file": "/monitors.md"
|
||||
},
|
||||
{
|
||||
"title": "API/Website Monitor",
|
||||
"link": "/docs/monitors-api",
|
||||
"file": "/monitors-api.md"
|
||||
},
|
||||
{
|
||||
"title": "Ping Monitor",
|
||||
"link": "/docs/monitors-ping",
|
||||
"file": "/monitors-ping.md"
|
||||
},
|
||||
{
|
||||
"title": "TCP Monitor",
|
||||
"link": "/docs/monitors-tcp",
|
||||
"file": "/monitors-tcp.md"
|
||||
},
|
||||
{
|
||||
"title": "DNS Monitor",
|
||||
"link": "/docs/monitors-dns",
|
||||
"file": "/monitors-dns.md"
|
||||
},
|
||||
{
|
||||
"title": "Group Monitor",
|
||||
"link": "/docs/monitors-group",
|
||||
"file": "/monitors-group.md"
|
||||
},
|
||||
{
|
||||
"title": "Setup Triggers",
|
||||
"link": "/docs/triggers",
|
||||
"file": "/triggers.md"
|
||||
},
|
||||
{
|
||||
"title": "Setup Site",
|
||||
"link": "/docs/site",
|
||||
"file": "/site.md"
|
||||
},
|
||||
{
|
||||
"title": "Setup SEO",
|
||||
"link": "/docs/seo",
|
||||
"file": "/seo.md"
|
||||
},
|
||||
{
|
||||
"title": "Setup Home",
|
||||
"link": "/docs/home-page",
|
||||
"file": "/home-page.md"
|
||||
},
|
||||
{
|
||||
"title": "Setup Theme",
|
||||
"link": "/docs/theme",
|
||||
"file": "/theme.md"
|
||||
},
|
||||
{
|
||||
"title": "View Alerts",
|
||||
"link": "/docs/alerts",
|
||||
"file": "/alerts.md"
|
||||
},
|
||||
{
|
||||
"title": "API Keys",
|
||||
"link": "/docs/apikeys",
|
||||
"file": "/apikeys.md"
|
||||
},
|
||||
|
||||
{
|
||||
"title": "Incident Management",
|
||||
"link": "/docs/incident-management",
|
||||
"file": "/incident-management.md"
|
||||
},
|
||||
{
|
||||
"title": "Embed",
|
||||
"link": "/docs/embed",
|
||||
"file": "/embed.md"
|
||||
},
|
||||
{
|
||||
"title": "Custom JS/CSS",
|
||||
"link": "/docs/custom-js-css-guide",
|
||||
"file": "/custom-js-css-guide.md"
|
||||
},
|
||||
{
|
||||
"title": "Internationalization",
|
||||
"link": "/docs/i18n",
|
||||
"file": "/i18n.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sectionTitle": "API Reference",
|
||||
"children": [
|
||||
{
|
||||
"title": "Kener APIs",
|
||||
"link": "/docs/kener-apis",
|
||||
"file": "/kener-apis.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sectionTitle": "Help",
|
||||
"children": [
|
||||
{
|
||||
"title": "Fonts",
|
||||
"link": "/docs/custom-fonts",
|
||||
"file": "/custom-fonts.md"
|
||||
},
|
||||
{
|
||||
"title": "Changelogs",
|
||||
"link": "/docs/changelogs",
|
||||
"file": "/changelogs.md"
|
||||
},
|
||||
{
|
||||
"title": "Roadmap",
|
||||
"link": "/docs/roadmap",
|
||||
"file": "/roadmap.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
{
|
||||
"title": "Incident Management",
|
||||
"link": "/docs/incident-management",
|
||||
"file": "/incident-management.md"
|
||||
},
|
||||
{
|
||||
"title": "Embed",
|
||||
"link": "/docs/embed",
|
||||
"file": "/embed.md"
|
||||
},
|
||||
{
|
||||
"title": "Custom JS/CSS",
|
||||
"link": "/docs/custom-js-css-guide",
|
||||
"file": "/custom-js-css-guide.md"
|
||||
},
|
||||
{
|
||||
"title": "Internationalization",
|
||||
"link": "/docs/i18n",
|
||||
"file": "/i18n.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sectionTitle": "API Reference",
|
||||
"children": [
|
||||
{
|
||||
"title": "Kener APIs",
|
||||
"link": "/docs/kener-apis",
|
||||
"file": "/kener-apis.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sectionTitle": "Help",
|
||||
"children": [
|
||||
{
|
||||
"title": "Fonts",
|
||||
"link": "/docs/custom-fonts",
|
||||
"file": "/custom-fonts.md"
|
||||
},
|
||||
{
|
||||
"title": "Changelogs",
|
||||
"link": "/docs/changelogs",
|
||||
"file": "/changelogs.md"
|
||||
},
|
||||
{
|
||||
"title": "Roadmap",
|
||||
"link": "/docs/roadmap",
|
||||
"file": "/roadmap.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ Kener provides you with the ability to customize the theme of your status page.
|
||||
|
||||
## Home Page Pattern
|
||||
|
||||
Kener can show a subtle pattern in all your pages. It is either sqaure or dots. Right now you cannot modify the color of the pattern. However, you can disable it by choosing none
|
||||
Kener can show a subtle pattern in all your pages. It is either square or dots. Right now you cannot modify the color of the pattern. However, you can disable it by choosing none
|
||||
|
||||
---
|
||||
|
||||
@@ -35,13 +35,13 @@ You can change how the bars and summary of a monitor looks like.
|
||||
|
||||
The status bar will be a gradient from green to red/yellow based on the status of the monitor.
|
||||
|
||||

|
||||

|
||||
|
||||
#### Full
|
||||
|
||||
The status bar will be a solid color based on the status of the monitor.
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -51,11 +51,11 @@ Adjust the roundness of the status bar.
|
||||
|
||||
#### SHARP
|
||||
|
||||

|
||||

|
||||
|
||||
#### ROUNDED
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -97,7 +97,7 @@ You can add custom CSS to your status page. This will be added to the head of th
|
||||
|
||||
```css
|
||||
.my-class {
|
||||
color: red;
|
||||
color: red;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Triggers are used to trigger actions based on the status of your monitors. You c
|
||||
|
||||
<div class="border rounded-md">
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ Webhook triggers are used to send a HTTP POST request to a URL when a monitor go
|
||||
|
||||
<div class="border rounded-md">
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -72,24 +72,24 @@ Body of the webhook will be sent as below:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "mockoon-9",
|
||||
"alert_name": "Mockoon DOWN",
|
||||
"severity": "critical",
|
||||
"status": "TRIGGERED",
|
||||
"source": "Kener",
|
||||
"timestamp": "2024-11-27T04:55:00.369Z",
|
||||
"description": "🚨 **Service Alert**: Check the details below",
|
||||
"details": {
|
||||
"metric": "Mockoon",
|
||||
"current_value": 1,
|
||||
"threshold": 1
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"text": "View Monitor",
|
||||
"url": "https://kener.ing/monitor-mockoon"
|
||||
}
|
||||
]
|
||||
"id": "mockoon-9",
|
||||
"alert_name": "Mockoon DOWN",
|
||||
"severity": "critical",
|
||||
"status": "TRIGGERED",
|
||||
"source": "Kener",
|
||||
"timestamp": "2024-11-27T04:55:00.369Z",
|
||||
"description": "🚨 **Service Alert**: Check the details below",
|
||||
"details": {
|
||||
"metric": "Mockoon",
|
||||
"current_value": 1,
|
||||
"threshold": 1
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"text": "View Monitor",
|
||||
"url": "https://kener.ing/monitor-mockoon"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -118,7 +118,7 @@ Discord triggers are used to send a message to a discord channel when a monitor
|
||||
|
||||
<div class="border rounded-md">
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,7 @@ The discord message when alert is `TRIGGERED` will look like this
|
||||
|
||||
The discord message when alert is `RESOLVED` will look like this
|
||||
|
||||

|
||||

|
||||
|
||||
## Slack
|
||||
|
||||
@@ -155,7 +155,7 @@ Slack triggers are used to send a message to a slack channel when a monitor goes
|
||||
|
||||
<div class="border rounded-md">
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -185,7 +185,7 @@ The slack message when alert is `TRIGGERED` will look like this
|
||||
|
||||
The slack message when alert is `RESOLVED` will look like this
|
||||
|
||||

|
||||

|
||||
|
||||
## Email
|
||||
|
||||
@@ -193,7 +193,7 @@ Email triggers are used to send an email when a monitor goes down or up. Kener s
|
||||
|
||||
<div class="border rounded-md">
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -216,6 +216,12 @@ Since the password will be stored as plain text we encourage to use environment
|
||||
|
||||
</div>
|
||||
|
||||
<div class=" note info ">
|
||||
|
||||
If your SMTP provider does require username and password, you can set `SMTP_USER` and `SMTP_PASS` to `-`.
|
||||
|
||||
</div>
|
||||
|
||||
### To
|
||||
|
||||
<span class="text-red-500 text-xs font-semibold">
|
||||
@@ -250,11 +256,11 @@ Subject of the email when `RESOLVED`
|
||||
|
||||
The emaik message when alert is `TRIGGERED` will look like this
|
||||
|
||||

|
||||

|
||||
|
||||
The emaik message when alert is `RESOLVED` will look like this
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -278,9 +284,9 @@ Set the URL to `https://api.telegram.org/bot[BOT_TOKEN]/sendMessage`. Replace [B
|
||||
|
||||
```json
|
||||
{
|
||||
"chat_id": "[CHAT_ID]", // Replace [CHAT_ID] with your chat id
|
||||
"text": "<b>${alert_name}</b>\n\n<b>Severity:</b> <code>${severity}</code>\n<b>Status:</b> ${status}\n<b>Source:</b> Kener\n<b>Time:</b> ${timestamp}\n\n📌 <b>Details:</b>\n- <b>Metric:</b>${metric}\n- <b>Current Value:</b> <code>${current_value}</code>\n- <b>Threshold:</b> <code>${threshold}</code>\n\n🔍 <a href=\"${action_url}\">${action_text}</a>",
|
||||
"parse_mode": "HTML"
|
||||
"chat_id": "[CHAT_ID]", // Replace [CHAT_ID] with your chat id
|
||||
"text": "<b>${alert_name}</b>\n\n<b>Severity:</b> <code>${severity}</code>\n<b>Status:</b> ${status}\n<b>Source:</b> Kener\n<b>Time:</b> ${timestamp}\n\n📌 <b>Details:</b>\n- <b>Metric:</b>${metric}\n- <b>Current Value:</b> <code>${current_value}</code>\n- <b>Threshold:</b> <code>${threshold}</code>\n\n🔍 <a href=\"${action_url}\">${action_text}</a>",
|
||||
"parse_mode": "HTML"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Automatically set PUBLIC_WHITE_LABEL based on WHITE_LABEL
|
||||
export PUBLIC_WHITE_LABEL="${WHITE_LABEL}"
|
||||
|
||||
# Replace shell with the given command (from CMD or runtime args)
|
||||
exec "$@"
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kener",
|
||||
"version": "3.0.12",
|
||||
"version": "3.1.7",
|
||||
"private": false,
|
||||
"license": "MIT",
|
||||
"description": "Kener: An open-source Node.js status page application for real-time service monitoring, incident management, and customizable reporting. Simplify service outage tracking, enhance incident communication, and ensure a seamless user experience.",
|
||||
@@ -37,6 +37,7 @@
|
||||
"devschedule": "node src/lib/server/startup.js",
|
||||
"schedule": "node src/lib/server/startup.js",
|
||||
"development": "vite dev",
|
||||
"generate-readme": "node scripts/generate-readme.js",
|
||||
"dev": "npm-run-all --parallel devschedule development",
|
||||
"prettify": "prettier --write .",
|
||||
"start": "node main.js"
|
||||
@@ -51,6 +52,7 @@
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"date-picker-svelte": "^2.15.1",
|
||||
"mustache": "^4.2.0",
|
||||
"postcss": "^8.4.24",
|
||||
"postcss-load-config": "^4.0.1",
|
||||
"prettier": "^3.2.5",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import fs from "fs";
|
||||
import Mustache from "mustache";
|
||||
import dotenv from "dotenv";
|
||||
import { fileURLToPath } from "url";
|
||||
import path from "path";
|
||||
|
||||
// Load environment variables from .env file
|
||||
dotenv.config();
|
||||
|
||||
// Resolve paths correctly in ES modules
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Load the template
|
||||
const templatePath = path.resolve(__dirname, "../README.template.md");
|
||||
const template = fs.readFileSync(templatePath, "utf-8");
|
||||
|
||||
// Load environment variables and provide default values
|
||||
const data = {
|
||||
kener_full_version: process.env.BUILD_FULL_VERSION || "N/A",
|
||||
kener_major_version: process.env.BUILD_MAJOR_VERSION || "N/A",
|
||||
kener_major_minor_version: process.env.BUILD_MAJOR_MINOR_VERSION || "N/A",
|
||||
};
|
||||
|
||||
// Render README.md
|
||||
const output = Mustache.render(template, data);
|
||||
|
||||
// Write to README.md
|
||||
fs.writeFileSync(path.resolve(__dirname, "../README.md"), output);
|
||||
|
||||
console.log("✅ README.md generated successfully!");
|
||||
@@ -1,45 +0,0 @@
|
||||
//read from process.env.PUBLIC_KENER_FOLDER / site.json
|
||||
//read from process.env.PUBLIC_KENER_FOLDER / monitors.json
|
||||
// create sitemap.xml
|
||||
import fs from "fs-extra";
|
||||
let siteMap = "";
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
const site = fs.readJSONSync("./database/site.json", "utf8");
|
||||
const monitors = fs.readJSONSync("./database/monitors.json", "utf8");
|
||||
if (site.siteURL !== undefined && site.siteURL !== null && site.siteURL !== "") {
|
||||
if (monitors.length > 0) {
|
||||
siteMap = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset
|
||||
xmlns="https://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="https://www.sitemaps.org/schemas/sitemap/0.9
|
||||
https://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||
${monitors
|
||||
.map((monitor) => {
|
||||
return `
|
||||
<url>
|
||||
<loc>${site.siteURL}/incident/${monitor.tag}</loc>
|
||||
<lastmod>${new Date().toISOString()}</lastmod>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>`;
|
||||
})
|
||||
.join("\n")}
|
||||
${monitors
|
||||
.map((monitor) => {
|
||||
return `
|
||||
<url>
|
||||
<loc>${site.siteURL}/monitor-${encodeURIComponent(monitor.tag)}</loc>
|
||||
<lastmod>${new Date().toISOString()}</lastmod>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>`;
|
||||
})
|
||||
.join("\n")}
|
||||
</urlset>`;
|
||||
}
|
||||
}
|
||||
|
||||
//export default siteMap
|
||||
export default siteMap;
|
||||
@@ -1,88 +1,90 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss/base";
|
||||
|
||||
@import "tailwindcss/components";
|
||||
@import "tailwindcss/utilities";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--background-kener: hsl(0, 0%, 100%);
|
||||
--background-kener-rgba: rgba(255, 255, 255, 0.5);
|
||||
--foreground: 240 10% 4%;
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--background-kener: hsl(0, 0%, 100%);
|
||||
--background-kener-rgba: rgba(255, 255, 255, 0.5);
|
||||
|
||||
--muted: 0 0% 92.9%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--foreground: 240 10% 4%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 4%;
|
||||
--muted: 0 0% 92.9%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
--card: 0 0% 99%;
|
||||
--card-foreground: 240 10% 4%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 4%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--card: 0 0% 99%;
|
||||
--card-foreground: 240 10% 4%;
|
||||
|
||||
--date-picker-background: hsl(0 0% 99%);
|
||||
--date-picker-foreground: hsl(240 10% 4%);
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--date-picker-background: hsl(0 0% 99%);
|
||||
--date-picker-foreground: hsl(240 10% 4%);
|
||||
|
||||
--secondary: 0 0% 92.9%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 0 0% 92.9%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: 0 72.2% 50.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--ring: 240 10% 4%;
|
||||
--destructive: 0 72.2% 50.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
--ring: 240 10% 4%;
|
||||
|
||||
.dark {
|
||||
--background: 240 7% 11%;
|
||||
--background-kener: hsl(240 7% 11%);
|
||||
--background-kener-rgba: rgba(30, 30, 26, 0.35);
|
||||
--foreground: 60 10% 86%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
--muted: 240 4% 16%;
|
||||
--muted-foreground: 0 0% 40%;
|
||||
.dark {
|
||||
--background: 240 7% 11%;
|
||||
--background-kener: hsl(240 7% 11%);
|
||||
--background-kener-rgba: rgba(30, 30, 26, 0.35);
|
||||
--foreground: 60 10% 86%;
|
||||
|
||||
--popover: 230 9% 14%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--muted: 240 4% 16%;
|
||||
--muted-foreground: 0 0% 40%;
|
||||
|
||||
--card: 230 9% 14%;
|
||||
--card-foreground: 60 10% 86%;
|
||||
--popover: 230 9% 14%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--border: 60 1% 17%;
|
||||
--input: 60 6% 17%;
|
||||
--card: 230 9% 14%;
|
||||
--card-foreground: 60 10% 86%;
|
||||
|
||||
--date-picker-background: hsl(240 4% 16%);
|
||||
--date-picker-foreground: hsl(60 10% 86%);
|
||||
--border: 60 1% 17%;
|
||||
--input: 60 6% 17%;
|
||||
|
||||
--primary: 60 10% 86%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--date-picker-background: hsl(240 4% 16%);
|
||||
--date-picker-foreground: hsl(60 10% 86%);
|
||||
|
||||
--secondary: 240 1% 18%;
|
||||
--secondary-foreground: 60 10% 86%;
|
||||
--primary: 60 10% 86%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--accent: 240 4% 16%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--secondary: 240 1% 18%;
|
||||
--secondary-foreground: 60 10% 86%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--accent: 240 4% 16%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--ring: hsl(212.7, 26.8%, 83.9);
|
||||
}
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--ring: hsl(212.7, 26.8%, 83.9);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +1,82 @@
|
||||
code:not([class^="language-"]) {
|
||||
@apply rounded bg-gray-100 px-1.5 py-0.5 font-mono text-xs dark:bg-gray-800;
|
||||
@apply rounded bg-gray-100 px-1.5 py-0.5 font-mono text-xs dark:bg-gray-800;
|
||||
}
|
||||
|
||||
.sidebar-item.active,
|
||||
.sidebar-item:hover {
|
||||
color: #ed702d;
|
||||
color: #ed702d;
|
||||
}
|
||||
|
||||
.w-585px {
|
||||
width: 585px;
|
||||
width: 585px;
|
||||
}
|
||||
|
||||
main {
|
||||
scroll-behavior: smooth;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
.kener-home-links a {
|
||||
text-decoration: none;
|
||||
background-color: var(--bg-background);
|
||||
text-decoration: none;
|
||||
background-color: var(--bg-background);
|
||||
}
|
||||
.kener-home-links > div:hover {
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 0 8px 1.5px #3e9a4b;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 0 8px 1.5px #3e9a4b;
|
||||
}
|
||||
|
||||
.accm input:checked ~ div {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
.accm input ~ div {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.accm input {
|
||||
visibility: hidden;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.accmt {
|
||||
background-color: hsl(223, 10%, 14%);
|
||||
background-color: hsl(223, 10%, 14%);
|
||||
}
|
||||
|
||||
.accm input:checked ~ .showaccm span:first-child {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
.accm input:checked ~ .showaccm span:last-child {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
.accm input ~ .showaccm span:first-child {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
.accm input ~ .showaccm span:last-child {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hljs {
|
||||
background: transparent !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.note {
|
||||
@apply mt-4 rounded-md border bg-background p-3 text-sm shadow-sm;
|
||||
@apply mt-4 rounded-md border bg-background p-3 text-sm shadow-sm;
|
||||
}
|
||||
.note.danger {
|
||||
border: 1px solid #e3342f;
|
||||
color: #e3342f;
|
||||
border: 1px solid #e3342f;
|
||||
color: #e3342f;
|
||||
}
|
||||
|
||||
.note.info {
|
||||
border: 1px solid #3490dc;
|
||||
color: #3490dc;
|
||||
border: 1px solid #3490dc;
|
||||
color: #3490dc;
|
||||
}
|
||||
|
||||
.copybtn .copy-btn {
|
||||
transform: scale(1);
|
||||
}
|
||||
.copybtn .check-btn {
|
||||
transform: scale(0);
|
||||
}
|
||||
.copybtn:focus .copy-btn {
|
||||
transform: scale(0);
|
||||
}
|
||||
.copybtn:focus .check-btn {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
src:
|
||||
url("./fonts/lato/latin_subset/lato-hairline.woff2") format("woff2"),
|
||||
url("./fonts/lato/latin_subset/lato-hairline.woff") format("woff"),
|
||||
url("./fonts/lato/latin_subset/lato-hairline.ttf") format("truetype");
|
||||
font-weight: 100;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
src:
|
||||
url("./fonts/lato/latin_subset/lato-hairline-italic.woff2") format("woff2"),
|
||||
url("./fonts/lato/latin_subset/lato-hairline-italic.woff") format("woff"),
|
||||
url("./fonts/lato/latin_subset/lato-hairline-italic.ttf") format("truetype");
|
||||
font-weight: 100;
|
||||
font-style: italic;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
src:
|
||||
url("./fonts/lato/latin_subset/lato-light.woff2") format("woff2"),
|
||||
url("./fonts/lato/latin_subset/lato-light.woff") format("woff"),
|
||||
url("./fonts/lato/latin_subset/lato-light.ttf") format("truetype");
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
src:
|
||||
url("./fonts/lato/latin_subset/lato-light-italic.woff2") format("woff2"),
|
||||
url("./fonts/lato/latin_subset/lato-light-italic.woff") format("woff"),
|
||||
url("./fonts/lato/latin_subset/lato-light-italic.ttf") format("truetype");
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
src:
|
||||
url("./fonts/lato/latin_subset/lato-regular.woff2") format("woff2"),
|
||||
url("./fonts/lato/latin_subset/lato-regular.woff") format("woff"),
|
||||
url("./fonts/lato/latin_subset/lato-regular.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
src:
|
||||
url("./fonts/lato/latin_subset/lato-regular-italic.woff2") format("woff2"),
|
||||
url("./fonts/lato/latin_subset/lato-regular-italic.woff") format("woff"),
|
||||
url("./fonts/lato/latin_subset/lato-regular-italic.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
src:
|
||||
url("./fonts/lato/latin_subset/lato-bold.woff2") format("woff2"),
|
||||
url("./fonts/lato/latin_subset/lato-bold.woff") format("woff"),
|
||||
url("./fonts/lato/latin_subset/lato-bold.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
src:
|
||||
url("./fonts/lato/latin_subset/lato-bold-italic.woff2") format("woff2"),
|
||||
url("./fonts/lato/latin_subset/lato-bold-italic.woff") format("woff"),
|
||||
url("./fonts/lato/latin_subset/lato-bold-italic.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
src:
|
||||
url("./fonts/lato/latin_subset/lato-black.woff2") format("woff2"),
|
||||
url("./fonts/lato/latin_subset/lato-black.woff") format("woff"),
|
||||
url("./fonts/lato/latin_subset/lato-black.ttf") format("truetype");
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
src:
|
||||
url("./fonts/lato/latin_subset/lato-black-italic.woff2") format("woff2"),
|
||||
url("./fonts/lato/latin_subset/lato-black-italic.woff") format("woff"),
|
||||
url("./fonts/lato/latin_subset/lato-black-italic.ttf") format("truetype");
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -1,175 +1,174 @@
|
||||
<script>
|
||||
import { formatDistanceToNow, formatDistance } from "date-fns";
|
||||
import { formatDistanceToNow, formatDistance } from "date-fns";
|
||||
import { Settings } from "lucide-svelte";
|
||||
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 GMI from "$lib/components/gmi.svelte";
|
||||
import { page } from "$app/stores";
|
||||
export let incident;
|
||||
export let index;
|
||||
export let lang;
|
||||
export let selectedLang = "en";
|
||||
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, selectedLang);
|
||||
const startedAt = fdn(startTime, selectedLang);
|
||||
|
||||
import * as Accordion from "$lib/components/ui/accordion";
|
||||
import { l, f, fd, fdn } from "$lib/i18n/client";
|
||||
import { base } from "$app/paths";
|
||||
export let incident;
|
||||
export let index;
|
||||
export let lang;
|
||||
export let selectedLang = "en";
|
||||
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, selectedLang);
|
||||
const startedAt = fdn(startTime, selectedLang);
|
||||
let isFuture = false;
|
||||
//is future incident
|
||||
if (nowTime < startTime) {
|
||||
isFuture = true;
|
||||
}
|
||||
|
||||
let isFuture = false;
|
||||
//is future incident
|
||||
if (nowTime < startTime) {
|
||||
isFuture = true;
|
||||
}
|
||||
let accordionValue = "incident-0";
|
||||
if ($page.data.site.incidentGroupView == "COLLAPSED") {
|
||||
accordionValue = "incident-collapse";
|
||||
} else if ($page.data.site.incidentGroupView == "EXPANDED") {
|
||||
accordionValue = index;
|
||||
}
|
||||
|
||||
let incidentDateSummary = "";
|
||||
let maintenanceBadge = "";
|
||||
let maintenanceBadgeColor = "";
|
||||
if (!isFuture && incident.state != "RESOLVED") {
|
||||
incidentDateSummary = l(lang, "Started %startedAt, still ongoing", {
|
||||
startedAt
|
||||
});
|
||||
maintenanceBadge = "Maintenance in Progress";
|
||||
maintenanceBadgeColor = "text-maintenance-in-progress";
|
||||
} else if (!isFuture && incident.state == "RESOLVED") {
|
||||
incidentDateSummary = l(lang, "Started %startedAt, lasted for %lastedFor", {
|
||||
startedAt,
|
||||
lastedFor
|
||||
});
|
||||
maintenanceBadge = "Maintenance Completed";
|
||||
maintenanceBadgeColor = "text-maintenance-completed";
|
||||
} else if (isFuture && incident.state != "RESOLVED") {
|
||||
incidentDateSummary = l(lang, "Starts %startedAt", { startedAt });
|
||||
maintenanceBadge = "Upcoming Maintenance";
|
||||
maintenanceBadgeColor = "text-upcoming-maintenance";
|
||||
} else if (isFuture && incident.state == "RESOLVED") {
|
||||
incidentDateSummary = l(lang, "Starts %startedAt, will last for %lastedFor", {
|
||||
startedAt,
|
||||
lastedFor
|
||||
});
|
||||
maintenanceBadge = "Upcoming Maintenance";
|
||||
maintenanceBadgeColor = "text-upcoming-maintenance";
|
||||
}
|
||||
let incidentDateSummary = "";
|
||||
let maintenanceBadge = "";
|
||||
let maintenanceBadgeColor = "";
|
||||
if (!isFuture && incident.state != "RESOLVED") {
|
||||
incidentDateSummary = l(lang, "Started %startedAt, still ongoing", {
|
||||
startedAt
|
||||
});
|
||||
maintenanceBadge = "Maintenance in Progress";
|
||||
maintenanceBadgeColor = "text-maintenance-in-progress";
|
||||
} else if (!isFuture && incident.state == "RESOLVED") {
|
||||
incidentDateSummary = l(lang, "Started %startedAt, lasted for %lastedFor", {
|
||||
startedAt,
|
||||
lastedFor
|
||||
});
|
||||
maintenanceBadge = "Maintenance Completed";
|
||||
maintenanceBadgeColor = "text-maintenance-completed";
|
||||
} else if (isFuture && incident.state != "RESOLVED") {
|
||||
incidentDateSummary = l(lang, "Starts %startedAt", { startedAt });
|
||||
maintenanceBadge = "Upcoming Maintenance";
|
||||
maintenanceBadgeColor = "text-upcoming-maintenance";
|
||||
} else if (isFuture && incident.state == "RESOLVED") {
|
||||
incidentDateSummary = l(lang, "Starts %startedAt, will last for %lastedFor", {
|
||||
startedAt,
|
||||
lastedFor
|
||||
});
|
||||
maintenanceBadge = "Upcoming Maintenance";
|
||||
maintenanceBadgeColor = "text-upcoming-maintenance";
|
||||
}
|
||||
</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">
|
||||
<Accordion.Item value="incident-0">
|
||||
<Accordion.Trigger class="px-4 hover:bg-muted hover:no-underline">
|
||||
<div class="w-full text-left hover:no-underline">
|
||||
{#if incidentType == "INCIDENT"}
|
||||
<p
|
||||
class="scroll-m-20 text-xs font-semibold leading-5 tracking-normal badge-{incident.state}"
|
||||
>
|
||||
{l(lang, incident.state)}
|
||||
</p>
|
||||
{:else if incidentType == "MAINTENANCE"}
|
||||
<p
|
||||
class="{maintenanceBadgeColor} scroll-m-20 text-xs font-semibold leading-5 tracking-normal"
|
||||
>
|
||||
{l(lang, maintenanceBadge)}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="col-span-12">
|
||||
<Accordion.Root bind:value={index} class="accor">
|
||||
<Accordion.Item value={accordionValue}>
|
||||
<Accordion.Trigger class="px-4 hover:bg-muted hover:no-underline">
|
||||
<div class="w-full text-left hover:no-underline">
|
||||
<p class="flex gap-x-2 text-xs font-semibold">
|
||||
{#if incidentType == "INCIDENT"}
|
||||
<span class="badge-{incident.state}">
|
||||
{l(lang, incident.state)}
|
||||
</span>
|
||||
{:else if incidentType == "MAINTENANCE"}
|
||||
<span class="{maintenanceBadgeColor} ">
|
||||
{l(lang, maintenanceBadge)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if $page.data.isLoggedIn}
|
||||
<Button
|
||||
href="{base}/manage/app/events#{incident.id}"
|
||||
class=" rotate-once h-5 p-0 text-muted-foreground hover:text-primary"
|
||||
variant="link"
|
||||
>
|
||||
<Settings class="h-4 w-4 " />
|
||||
</Button>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<p class="scroll-m-20 text-lg font-medium tracking-tight">
|
||||
{incident.title}
|
||||
</p>
|
||||
{#if !!incidentDateSummary}
|
||||
<p
|
||||
class="scroll-m-20 text-sm font-medium tracking-wide text-muted-foreground"
|
||||
>
|
||||
{incidentDateSummary}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
<div class="px-4 pt-2">
|
||||
{#if incident.monitors.length > 0}
|
||||
<div class="flex gap-2">
|
||||
{#each incident.monitors as monitor}
|
||||
<div
|
||||
class="tag-affected-text flex gap-x-2 rounded-md bg-secondary px-1 py-1 pr-2"
|
||||
>
|
||||
<div
|
||||
class="bg-api-{monitor.impact_type.toLowerCase()} rounded px-1.5 py-1 text-xs font-semibold text-primary-foreground"
|
||||
>
|
||||
{monitor.impact_type}
|
||||
</div>
|
||||
{#if monitor.image}
|
||||
<img src={base + monitor.image} class="mt-1 h-4 w-4" />
|
||||
{/if}
|
||||
<div class="mt-0.5 font-medium">
|
||||
{monitor.name}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<p class="my-3 text-xs font-semibold uppercase text-muted-foreground">
|
||||
{l(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="absolute top-0 w-28 -translate-x-32 rounded border bg-secondary px-1.5 py-1 text-center text-xs font-semibold"
|
||||
>
|
||||
{l(lang, comment.state)}
|
||||
</div>
|
||||
<p class="scroll-m-20 text-lg font-medium tracking-tight">
|
||||
{incident.title}
|
||||
</p>
|
||||
{#if !!incidentDateSummary}
|
||||
<p class="scroll-m-20 text-sm font-medium tracking-wide text-muted-foreground">
|
||||
{incidentDateSummary}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
<div class="px-4 pt-2">
|
||||
{#if incident.monitors.length > 0}
|
||||
<div class="flex gap-2">
|
||||
{#each incident.monitors as monitor}
|
||||
<div class="tag-affected-text flex gap-x-2 rounded-md bg-secondary px-1 py-1 pr-2">
|
||||
<div
|
||||
class="bg-api-{monitor.impact_type.toLowerCase()} rounded px-1.5 py-1 text-xs font-semibold text-primary-foreground"
|
||||
>
|
||||
{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="my-3 text-xs font-semibold uppercase text-muted-foreground">
|
||||
{l(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="absolute top-0 w-28 -translate-x-32 rounded border bg-secondary px-1.5 py-1 text-center text-xs font-semibold"
|
||||
>
|
||||
{l(lang, comment.state)}
|
||||
</div>
|
||||
|
||||
<time
|
||||
class=" mb-1 text-sm font-medium leading-none text-muted-foreground"
|
||||
>
|
||||
{f(
|
||||
new Date(comment.commented_at * 1000),
|
||||
"MMMM do yyyy, h:mm:ss a",
|
||||
selectedLang
|
||||
)}
|
||||
</time>
|
||||
<time class=" mb-1 text-sm font-medium leading-none text-muted-foreground">
|
||||
{f(new Date(comment.commented_at * 1000), "MMMM do yyyy, h:mm:ss a", selectedLang)}
|
||||
</time>
|
||||
|
||||
<p class="mb-4 text-sm font-normal">
|
||||
{comment.comment}
|
||||
</p>
|
||||
</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=" mb-1 text-sm font-medium leading-none text-muted-foreground"
|
||||
>
|
||||
{f(
|
||||
new Date(comment.commented_at * 1000),
|
||||
"MMMM do yyyy, h:mm:ss a",
|
||||
selectedLang
|
||||
)}
|
||||
</time>
|
||||
<div class="mb-4 text-sm font-normal">
|
||||
{@html comment.comment}
|
||||
</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=" mb-1 text-sm font-medium leading-none text-muted-foreground">
|
||||
{f(new Date(comment.commented_at * 1000), "MMMM do yyyy, h:mm:ss a", selectedLang)}
|
||||
</time>
|
||||
|
||||
<p class="mb-2 text-sm font-normal">
|
||||
{comment.comment}
|
||||
</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text-sm font-medium">
|
||||
{l(lang, "No Updates Yet")}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</div>
|
||||
<p class="mb-2 text-sm font-normal">
|
||||
{comment.comment}
|
||||
</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text-sm font-medium">
|
||||
{l(lang, "No Updates Yet")}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</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} />
|
||||
@@ -1,209 +1,204 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { base } from "$app/paths";
|
||||
import moment from "moment";
|
||||
import { Loader, ChevronLeft, ChevronRight } from "lucide-svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { base } from "$app/paths";
|
||||
import moment from "moment";
|
||||
import { Loader, ChevronLeft, ChevronRight } from "lucide-svelte";
|
||||
|
||||
export let data;
|
||||
let pageNo = 1;
|
||||
let limit = 20;
|
||||
let alerts = [];
|
||||
let totalPages = 0;
|
||||
let loadingData = false;
|
||||
export let data;
|
||||
let pageNo = 1;
|
||||
let limit = 20;
|
||||
let alerts = [];
|
||||
let totalPages = 0;
|
||||
let loadingData = false;
|
||||
|
||||
let incidentURL = "";
|
||||
if (data.siteData.github && data.siteData.github.owner && data.siteData.github.repo) {
|
||||
incidentURL = `https://github.com/${data.siteData.github.owner}/${data.siteData.github.repo}/issues`;
|
||||
}
|
||||
let incidentURL = "";
|
||||
if (data.siteData.github && data.siteData.github.owner && data.siteData.github.repo) {
|
||||
incidentURL = `https://github.com/${data.siteData.github.owner}/${data.siteData.github.repo}/issues`;
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loadingData = true;
|
||||
try {
|
||||
let apiResp = await fetch(base + "/manage/app/api/", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: "getAllAlertsPaginated",
|
||||
data: { page: pageNo, limit }
|
||||
})
|
||||
});
|
||||
let resp = await apiResp.json();
|
||||
alerts = resp.alerts;
|
||||
totalPages = Math.ceil(resp.total.count / limit);
|
||||
} catch (error) {
|
||||
alert("Error: " + error);
|
||||
} finally {
|
||||
loadingData = false;
|
||||
}
|
||||
}
|
||||
async function loadData() {
|
||||
loadingData = true;
|
||||
try {
|
||||
let apiResp = await fetch(base + "/manage/app/api/", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: "getAllAlertsPaginated",
|
||||
data: { page: pageNo, limit }
|
||||
})
|
||||
});
|
||||
let resp = await apiResp.json();
|
||||
alerts = resp.alerts;
|
||||
totalPages = Math.ceil(resp.total.count / limit);
|
||||
} catch (error) {
|
||||
alert("Error: " + error);
|
||||
} finally {
|
||||
loadingData = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await loadData();
|
||||
});
|
||||
onMount(async () => {
|
||||
await loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="mb-4 flex justify-between">
|
||||
<div>
|
||||
{#if loadingData}
|
||||
<Loader class="mt-6 h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Pagination -->
|
||||
{#if totalPages > 1}
|
||||
<nav class="flex max-w-2xl items-center gap-x-1" aria-label="Pagination">
|
||||
<button
|
||||
disabled={pageNo === 1}
|
||||
type="button"
|
||||
class="inline-flex min-h-[38px] min-w-[38px] items-center justify-center gap-x-2 rounded-lg border border-transparent px-2.5 py-2 text-sm text-gray-800 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none disabled:pointer-events-none disabled:opacity-50 dark:border-transparent dark:text-white dark:hover:bg-white/10 dark:focus:bg-white/10"
|
||||
aria-label="Previous"
|
||||
on:click={() => {
|
||||
pageNo--;
|
||||
loadData();
|
||||
}}
|
||||
>
|
||||
<ChevronLeft class="size-3.5 shrink-0" />
|
||||
<span class="sr-only">Previous</span>
|
||||
</button>
|
||||
<div class="flex max-w-[380px] items-center gap-x-1 overflow-x-auto">
|
||||
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
|
||||
<button
|
||||
on:click={() => {
|
||||
pageNo = page;
|
||||
loadData();
|
||||
}}
|
||||
type="button"
|
||||
class="flex min-h-[38px] min-w-[38px] items-center justify-center rounded-lg border {page ==
|
||||
pageNo
|
||||
? 'border-gray-200'
|
||||
: 'border-transparent'} px-3 py-2 text-sm text-gray-800 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none disabled:pointer-events-none disabled:opacity-50 dark:border-transparent dark:text-white dark:hover:bg-white/10 dark:focus:bg-white/10"
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pageNo === totalPages}
|
||||
class="inline-flex min-h-[38px] min-w-[38px] items-center justify-center gap-x-2 rounded-lg border border-transparent px-2.5 py-2 text-sm text-gray-800 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none disabled:pointer-events-none disabled:opacity-50 dark:border-transparent dark:text-white dark:hover:bg-white/10 dark:focus:bg-white/10"
|
||||
aria-label="Next"
|
||||
on:click={() => {
|
||||
pageNo++;
|
||||
loadData();
|
||||
}}
|
||||
>
|
||||
<span class="sr-only">Next</span>
|
||||
<ChevronRight class="size-3.5 shrink-0" />
|
||||
</button>
|
||||
</nav>
|
||||
{/if}
|
||||
<!-- End Pagination -->
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="-m-1.5 overflow-x-auto">
|
||||
<div class="inline-block min-w-full p-1.5 align-middle">
|
||||
<div class="overflow-hidden rounded-lg border dark:border-neutral-700">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-neutral-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
|
||||
>ID</th
|
||||
>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
|
||||
>Type</th
|
||||
>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
|
||||
>Monitor</th
|
||||
>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
|
||||
>Status</th
|
||||
>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
|
||||
>Checks</th
|
||||
>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
|
||||
>Incident</th
|
||||
>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
|
||||
>Created At</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-neutral-700">
|
||||
{#each alerts as alert}
|
||||
<tr>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm font-medium">
|
||||
{alert.id}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-xs font-semibold">
|
||||
{#if alert.monitor_status === "DOWN"}
|
||||
<span class="text-red-500">
|
||||
{alert.monitor_status}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-yellow-500">
|
||||
{alert.monitor_status}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
{alert.monitor_tag}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-xs font-semibold">
|
||||
{#if alert.alert_status === "RESOLVED"}
|
||||
<span class="text-blue-500">
|
||||
{alert.alert_status}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-fuchsia-500">
|
||||
{alert.alert_status}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
{alert.health_checks}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-xs font-semibold">
|
||||
{#if !!alert.incident_number}
|
||||
<a
|
||||
target="_blank"
|
||||
href="{incidentURL}/{alert.incident_number}"
|
||||
class="text-cyan-500 hover:underline focus:outline-none"
|
||||
>
|
||||
{alert.incident_number}
|
||||
</a>
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
{moment
|
||||
.utc(alert.created_at, "YYYY-MM-DD HH:mm:ss")
|
||||
.local()
|
||||
.format("YYYY-MM-DD HH:mm:ss")}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 flex justify-between">
|
||||
<div>
|
||||
{#if loadingData}
|
||||
<Loader class="mt-6 h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Pagination -->
|
||||
{#if totalPages > 1}
|
||||
<nav class="flex max-w-2xl items-center gap-x-1" aria-label="Pagination">
|
||||
<button
|
||||
disabled={pageNo === 1}
|
||||
type="button"
|
||||
class="inline-flex min-h-[38px] min-w-[38px] items-center justify-center gap-x-2 rounded-lg border border-transparent px-2.5 py-2 text-sm text-gray-800 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none disabled:pointer-events-none disabled:opacity-50 dark:border-transparent dark:text-white dark:hover:bg-white/10 dark:focus:bg-white/10"
|
||||
aria-label="Previous"
|
||||
on:click={() => {
|
||||
pageNo--;
|
||||
loadData();
|
||||
}}
|
||||
>
|
||||
<ChevronLeft class="size-3.5 shrink-0" />
|
||||
<span class="sr-only">Previous</span>
|
||||
</button>
|
||||
<div class="flex max-w-[380px] items-center gap-x-1 overflow-x-auto">
|
||||
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
|
||||
<button
|
||||
on:click={() => {
|
||||
pageNo = page;
|
||||
loadData();
|
||||
}}
|
||||
type="button"
|
||||
class="flex min-h-[38px] min-w-[38px] items-center justify-center rounded-lg border {page == pageNo
|
||||
? 'border-gray-200'
|
||||
: 'border-transparent'} px-3 py-2 text-sm text-gray-800 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none disabled:pointer-events-none disabled:opacity-50 dark:border-transparent dark:text-white dark:hover:bg-white/10 dark:focus:bg-white/10"
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pageNo === totalPages}
|
||||
class="inline-flex min-h-[38px] min-w-[38px] items-center justify-center gap-x-2 rounded-lg border border-transparent px-2.5 py-2 text-sm text-gray-800 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none disabled:pointer-events-none disabled:opacity-50 dark:border-transparent dark:text-white dark:hover:bg-white/10 dark:focus:bg-white/10"
|
||||
aria-label="Next"
|
||||
on:click={() => {
|
||||
pageNo++;
|
||||
loadData();
|
||||
}}
|
||||
>
|
||||
<span class="sr-only">Next</span>
|
||||
<ChevronRight class="size-3.5 shrink-0" />
|
||||
</button>
|
||||
</nav>
|
||||
{/if}
|
||||
<!-- End Pagination -->
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="-m-1.5 overflow-x-auto">
|
||||
<div class="inline-block min-w-full p-1.5 align-middle">
|
||||
<div class="overflow-hidden rounded-lg border dark:border-neutral-700">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-neutral-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500">ID</th
|
||||
>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
|
||||
>Type</th
|
||||
>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
|
||||
>Monitor</th
|
||||
>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
|
||||
>Status</th
|
||||
>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
|
||||
>Checks</th
|
||||
>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
|
||||
>Incident</th
|
||||
>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
|
||||
>Created At</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-neutral-700">
|
||||
{#each alerts as alert}
|
||||
<tr>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm font-medium">
|
||||
{alert.id}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-xs font-semibold">
|
||||
{#if alert.monitor_status === "DOWN"}
|
||||
<span class="text-red-500">
|
||||
{alert.monitor_status}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-yellow-500">
|
||||
{alert.monitor_status}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
{alert.monitor_tag}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-xs font-semibold">
|
||||
{#if alert.alert_status === "RESOLVED"}
|
||||
<span class="text-blue-500">
|
||||
{alert.alert_status}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-fuchsia-500">
|
||||
{alert.alert_status}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
{alert.health_checks}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-xs font-semibold">
|
||||
{#if !!alert.incident_number}
|
||||
<a
|
||||
rel="external"
|
||||
href="{base}/manage/app/events#{alert.incident_number}"
|
||||
class="text-cyan-500 hover:underline focus:outline-none"
|
||||
>
|
||||
{alert.incident_number}
|
||||
</a>
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
{moment.utc(alert.created_at, "YYYY-MM-DD HH:mm:ss").local().format("YYYY-MM-DD HH:mm:ss")}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
<script>
|
||||
import { Button } from "$lib/components/ui/button"
|
||||
import { Plus, X, Loader } from "lucide-svelte"
|
||||
import { Input } from "$lib/components/ui/input"
|
||||
import { Label } from "$lib/components/ui/label"
|
||||
import { clickOutsideAction, slide } from "svelte-legos"
|
||||
import { ChevronRight, Trash } from "lucide-svelte"
|
||||
import { base } from "$app/paths"
|
||||
import * as Select from "$lib/components/ui/select"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Plus, X, Loader } from "lucide-svelte";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { clickOutsideAction, slide } from "svelte-legos";
|
||||
import { ChevronRight, Trash } from "lucide-svelte";
|
||||
import { base } from "$app/paths";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import GMI from "$lib/components/gmi.svelte";
|
||||
import {
|
||||
allRecordTypes,
|
||||
ValidateIpAddress,
|
||||
ValidateCronExpression,
|
||||
IsValidHost,
|
||||
IsValidNameServer
|
||||
} from "$lib/clientTools.js"
|
||||
} from "$lib/clientTools.js";
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const defaultEval = `(async function (statusCode, responseTime, responseData) {
|
||||
let statusCodeShort = Math.floor(statusCode/100);
|
||||
@@ -30,237 +31,255 @@
|
||||
status: 'DOWN',
|
||||
latency: responseTime,
|
||||
}
|
||||
})`
|
||||
export let categories = []
|
||||
let formState = "idle"
|
||||
})`;
|
||||
export let categories = [];
|
||||
let formState = "idle";
|
||||
|
||||
export let newMonitor;
|
||||
|
||||
export let newMonitor
|
||||
if (!!newMonitor && newMonitor.monitor_type == "PING" && !!!newMonitor.pingConfig.hosts) {
|
||||
newMonitor.pingConfig.hosts = []
|
||||
newMonitor.pingConfig.hosts = [];
|
||||
}
|
||||
let loadingLogo = false
|
||||
let loadingLogo = false;
|
||||
|
||||
let selectedCategory = categories.find((category) => category.name === newMonitor.category_name)
|
||||
let selectedCategory = categories.find((category) => category.name === newMonitor.category_name);
|
||||
|
||||
async function handleFileChangeLogo(event) {
|
||||
const file = event.target.files[0]
|
||||
const file = event.target.files[0];
|
||||
if (!file) {
|
||||
event.target.value = ""
|
||||
alert("Please select a file to upload.")
|
||||
return
|
||||
event.target.value = "";
|
||||
alert("Please select a file to upload.");
|
||||
return;
|
||||
}
|
||||
if (file.size > 100000) {
|
||||
event.target.value = ""
|
||||
alert("File size should be less than 100KB")
|
||||
return
|
||||
if (file.size > 102400) {
|
||||
event.target.value = "";
|
||||
alert("File size should be less than 100KB");
|
||||
return;
|
||||
}
|
||||
|
||||
loadingLogo = true
|
||||
const formData = new FormData()
|
||||
formData.append("image", file)
|
||||
loadingLogo = true;
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
try {
|
||||
const response = await fetch(base + "/manage/app/upload", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
newMonitor.image = "/uploads/" + result.filename
|
||||
const result = await response.json();
|
||||
newMonitor.image = "/uploads/" + result.filename;
|
||||
} else {
|
||||
alert("Failed to upload file.")
|
||||
alert("Failed to upload file.");
|
||||
}
|
||||
} catch (error) {
|
||||
alert("An error occurred while uploading the file.")
|
||||
alert("An error occurred while uploading the file.");
|
||||
} finally {
|
||||
loadingLogo = false
|
||||
loadingLogo = false;
|
||||
}
|
||||
}
|
||||
function addHeader() {
|
||||
newMonitor.apiConfig.headers = [...newMonitor.apiConfig.headers, { key: "", value: "" }]
|
||||
newMonitor.apiConfig.headers = [...newMonitor.apiConfig.headers, { key: "", value: "" }];
|
||||
}
|
||||
|
||||
let invalidFormMessage = ""
|
||||
let invalidFormMessage = "";
|
||||
|
||||
async function isValidEval(ev) {
|
||||
if (ev.endsWith(";")) {
|
||||
invalidFormMessage = "Eval should not end with semicolon"
|
||||
return false
|
||||
invalidFormMessage = "Eval should not end with semicolon";
|
||||
return false;
|
||||
}
|
||||
//has to start with ( and end with )
|
||||
if (!ev.startsWith("(") || !ev.endsWith(")")) {
|
||||
invalidFormMessage = "Eval should start with ( and end with ). It is an anonymous function"
|
||||
return false
|
||||
invalidFormMessage = "Eval should start with ( and end with ). It is an anonymous function";
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
// let evalResp = await eval(newMonitor.apiConfig.eval + `(200, 1000, "e30=")`);
|
||||
new Function(ev)
|
||||
return true // The code is valid
|
||||
new Function(ev);
|
||||
return true; // The code is valid
|
||||
} catch (error) {
|
||||
invalidFormMessage = error.message + " in eval."
|
||||
return false
|
||||
invalidFormMessage = error.message + " in eval.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const IsValidURL = function (url) {
|
||||
return /^(http|https):\/\/[^ "]+$/.test(url)
|
||||
}
|
||||
return /^(http|https):\/\/[^ "]+$/.test(url);
|
||||
};
|
||||
|
||||
async function saveOrUpdateMonitor() {
|
||||
invalidFormMessage = ""
|
||||
invalidFormMessage = "";
|
||||
//tag should alphanumeric hyphen underscore only
|
||||
const tagRegex = /^[a-zA-Z0-9_-]+$/
|
||||
const tagRegex = /^[a-zA-Z0-9_-]+$/;
|
||||
if (!tagRegex.test(newMonitor.tag)) {
|
||||
invalidFormMessage = "Tag should be alphanumeric, hyphen, underscore only"
|
||||
return
|
||||
invalidFormMessage = "Tag should be alphanumeric, hyphen, underscore only";
|
||||
return;
|
||||
}
|
||||
//name should be present
|
||||
if (!newMonitor.name) {
|
||||
invalidFormMessage = "Name is required"
|
||||
return
|
||||
invalidFormMessage = "Name is required";
|
||||
return;
|
||||
}
|
||||
//day_degraded_minimum_count should be positive number
|
||||
if (newMonitor.day_degraded_minimum_count < 1) {
|
||||
invalidFormMessage = "Day Degraded Minimum Count should be greater than 0"
|
||||
return
|
||||
invalidFormMessage = "Day Degraded Minimum Count should be greater than 0";
|
||||
return;
|
||||
}
|
||||
//day_down_minimum_count should be positive number
|
||||
if (newMonitor.day_down_minimum_count < 1) {
|
||||
invalidFormMessage = "Day Down Minimum Count should be greater than 0"
|
||||
return
|
||||
invalidFormMessage = "Day Down Minimum Count should be greater than 0";
|
||||
return;
|
||||
}
|
||||
|
||||
//validating cron
|
||||
const cron = newMonitor.cron
|
||||
let cronValidation = ValidateCronExpression(cron)
|
||||
const cron = newMonitor.cron;
|
||||
let cronValidation = ValidateCronExpression(cron);
|
||||
if (cronValidation.isValid === false) {
|
||||
invalidFormMessage = "Cron invalid: " + cronValidation.message
|
||||
return
|
||||
invalidFormMessage = "Cron invalid: " + cronValidation.message;
|
||||
return;
|
||||
}
|
||||
//if monitor type is API
|
||||
if (newMonitor.monitor_type === "API") {
|
||||
//validating url
|
||||
if (!newMonitor.apiConfig.url) {
|
||||
invalidFormMessage = "URL is required"
|
||||
return
|
||||
invalidFormMessage = "URL is required";
|
||||
return;
|
||||
}
|
||||
if (!IsValidURL(newMonitor.apiConfig.url)) {
|
||||
invalidFormMessage = "Invalid URL"
|
||||
return
|
||||
invalidFormMessage = "Invalid URL";
|
||||
return;
|
||||
}
|
||||
|
||||
//timeout should be positive number
|
||||
if (newMonitor.apiConfig.timeout < 1) {
|
||||
invalidFormMessage = "Timeout should be greater than 0"
|
||||
return
|
||||
invalidFormMessage = "Timeout should be greater than 0";
|
||||
return;
|
||||
}
|
||||
|
||||
//validating eval
|
||||
if (!!newMonitor.apiConfig.eval) {
|
||||
newMonitor.apiConfig.eval = newMonitor.apiConfig.eval.trim()
|
||||
newMonitor.apiConfig.eval = newMonitor.apiConfig.eval.trim();
|
||||
|
||||
if (!(await isValidEval(newMonitor.apiConfig.eval))) {
|
||||
invalidFormMessage = invalidFormMessage + ". Invalid eval"
|
||||
return
|
||||
invalidFormMessage = invalidFormMessage + ". Invalid eval";
|
||||
return;
|
||||
}
|
||||
}
|
||||
newMonitor.type_data = JSON.stringify(newMonitor.apiConfig)
|
||||
newMonitor.type_data = JSON.stringify(newMonitor.apiConfig);
|
||||
} else if (newMonitor.monitor_type === "PING") {
|
||||
let hosts = newMonitor.pingConfig.hosts
|
||||
let hosts = newMonitor.pingConfig.hosts;
|
||||
if (hosts && Array.isArray(hosts) && hosts.length > 0) {
|
||||
for (let i = 0; i < hosts.length; i++) {
|
||||
if (ValidateIpAddress(hosts[i].host) != hosts[i].type) {
|
||||
invalidFormMessage = `Host ${hosts[i].host} is not of type ${hosts[i].type}`
|
||||
return
|
||||
invalidFormMessage = `Host ${hosts[i].host} is not of type ${hosts[i].type}`;
|
||||
return;
|
||||
}
|
||||
//validating timeout
|
||||
if (hosts[i].timeout < 1) {
|
||||
invalidFormMessage = "Timeout should be greater than 0"
|
||||
return
|
||||
invalidFormMessage = "Timeout should be greater than 0";
|
||||
return;
|
||||
}
|
||||
//validating count
|
||||
if (hosts[i].count < 1) {
|
||||
invalidFormMessage = "Count should be greater than 0"
|
||||
return
|
||||
invalidFormMessage = "Count should be greater than 0";
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
invalidFormMessage = "Host is required"
|
||||
return
|
||||
invalidFormMessage = "Host is required";
|
||||
return;
|
||||
}
|
||||
if (!!newMonitor.pingConfig.pingEval) {
|
||||
newMonitor.pingConfig.pingEval = newMonitor.pingConfig.pingEval.trim()
|
||||
newMonitor.pingConfig.pingEval = newMonitor.pingConfig.pingEval.trim();
|
||||
if (!(await isValidEval(newMonitor.pingConfig.pingEval))) {
|
||||
invalidFormMessage = invalidFormMessage + ". Invalid eval"
|
||||
return
|
||||
invalidFormMessage = invalidFormMessage + ". Invalid eval";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
newMonitor.type_data = JSON.stringify(newMonitor.pingConfig)
|
||||
newMonitor.type_data = JSON.stringify(newMonitor.pingConfig);
|
||||
} else if (newMonitor.monitor_type === "TCP") {
|
||||
//validating hostsV4
|
||||
|
||||
let hosts = newMonitor.tcpConfig.hosts
|
||||
let hosts = newMonitor.tcpConfig.hosts;
|
||||
if (hosts && Array.isArray(hosts) && hosts.length > 0) {
|
||||
for (let i = 0; i < hosts.length; i++) {
|
||||
if (ValidateIpAddress(hosts[i].host) != hosts[i].type) {
|
||||
invalidFormMessage = `Host ${hosts[i].host} is not of type ${hosts[i].type}`
|
||||
return
|
||||
invalidFormMessage = `Host ${hosts[i].host} is not of type ${hosts[i].type}`;
|
||||
return;
|
||||
}
|
||||
//validating timeout
|
||||
if (hosts[i].timeout < 1) {
|
||||
invalidFormMessage = "Timeout should be greater than 0"
|
||||
return
|
||||
invalidFormMessage = "Timeout should be greater than 0";
|
||||
return;
|
||||
}
|
||||
//validating port
|
||||
if (hosts[i].port < 1 || hosts[i].port > 65535) {
|
||||
invalidFormMessage = "Port should be valid"
|
||||
return
|
||||
invalidFormMessage = "Port should be valid";
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
invalidFormMessage = "Host is required"
|
||||
return
|
||||
invalidFormMessage = "Host is required";
|
||||
return;
|
||||
}
|
||||
if (!!newMonitor.tcpConfig.tcpEval) {
|
||||
newMonitor.tcpConfig.tcpEval = newMonitor.tcpConfig.tcpEval.trim()
|
||||
newMonitor.tcpConfig.tcpEval = newMonitor.tcpConfig.tcpEval.trim();
|
||||
if (!(await isValidEval(newMonitor.tcpConfig.tcpEval))) {
|
||||
invalidFormMessage = invalidFormMessage + ". Invalid eval"
|
||||
return
|
||||
invalidFormMessage = invalidFormMessage + ". Invalid eval";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
newMonitor.type_data = JSON.stringify(newMonitor.tcpConfig)
|
||||
newMonitor.type_data = JSON.stringify(newMonitor.tcpConfig);
|
||||
} else if (newMonitor.monitor_type === "DNS") {
|
||||
//validating host
|
||||
if (!newMonitor.dnsConfig.host) {
|
||||
invalidFormMessage = "Host is required"
|
||||
return
|
||||
invalidFormMessage = "Host is required";
|
||||
return;
|
||||
}
|
||||
if (!IsValidHost(newMonitor.dnsConfig.host)) {
|
||||
invalidFormMessage = "Invalid Host"
|
||||
return
|
||||
invalidFormMessage = "Invalid Host";
|
||||
return;
|
||||
}
|
||||
//validating nameServer
|
||||
if (!newMonitor.dnsConfig.nameServer) {
|
||||
invalidFormMessage = "Name Server is required"
|
||||
return
|
||||
invalidFormMessage = "Name Server is required";
|
||||
return;
|
||||
}
|
||||
if (!IsValidNameServer(newMonitor.dnsConfig.nameServer)) {
|
||||
invalidFormMessage = "Invalid Name Server"
|
||||
return
|
||||
invalidFormMessage = "Invalid Name Server";
|
||||
return;
|
||||
}
|
||||
//atleast one value should be present
|
||||
//at least one value should be present
|
||||
if (
|
||||
!newMonitor.dnsConfig.values ||
|
||||
!Array.isArray(newMonitor.dnsConfig.values) ||
|
||||
newMonitor.dnsConfig.values.length === 0
|
||||
) {
|
||||
invalidFormMessage = "Atleast one value is required"
|
||||
return
|
||||
invalidFormMessage = "At least one value is required";
|
||||
return;
|
||||
}
|
||||
newMonitor.type_data = JSON.stringify(newMonitor.dnsConfig)
|
||||
newMonitor.type_data = JSON.stringify(newMonitor.dnsConfig);
|
||||
} else if (newMonitor.monitor_type === "GROUP") {
|
||||
let selectedOnly = newMonitor.groupConfig.monitors.filter((monitor) => monitor.selected);
|
||||
if (selectedOnly.length < 2) {
|
||||
invalidFormMessage = "Select at least 2 monitors";
|
||||
return;
|
||||
}
|
||||
//timeout >=1000
|
||||
if (newMonitor.groupConfig.timeout < 1000) {
|
||||
invalidFormMessage = "Timeout should be greater than or equal to 1000";
|
||||
return;
|
||||
}
|
||||
newMonitor.type_data = JSON.stringify({
|
||||
monitors: selectedOnly,
|
||||
timeout: parseInt(newMonitor.groupConfig.timeout),
|
||||
hideMonitors: newMonitor.groupConfig.hideMonitors
|
||||
});
|
||||
}
|
||||
formState = "loading"
|
||||
|
||||
formState = "loading";
|
||||
|
||||
try {
|
||||
let data = await fetch(base + "/manage/app/api/", {
|
||||
@@ -269,19 +288,20 @@
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ action: "storeMonitorData", data: newMonitor })
|
||||
})
|
||||
let resp = await data.json()
|
||||
});
|
||||
let resp = await data.json();
|
||||
if (resp.error) {
|
||||
invalidFormMessage = resp.error
|
||||
invalidFormMessage = resp.error;
|
||||
} else {
|
||||
dispatch("closeModal", {})
|
||||
dispatch("closeModal", {});
|
||||
}
|
||||
} catch (error) {
|
||||
invalidFormMessage = "Error while saving data"
|
||||
invalidFormMessage = "Error while saving data";
|
||||
} finally {
|
||||
formState = "idle"
|
||||
formState = "idle";
|
||||
}
|
||||
}
|
||||
let typeOfLogoUpload = newMonitor.image.startsWith("http") ? "URL" : "FILE";
|
||||
</script>
|
||||
|
||||
<div class="fixed left-0 top-0 z-50 h-screen w-screen bg-card bg-opacity-20 backdrop-blur-sm">
|
||||
@@ -289,7 +309,7 @@
|
||||
transition:slide={{ direction: "right", duration: 200 }}
|
||||
use:clickOutsideAction
|
||||
on:clickoutside={(e) => {
|
||||
dispatch("closeModal", {})
|
||||
dispatch("closeModal", {});
|
||||
}}
|
||||
class="absolute right-0 top-0 h-screen w-screen bg-background px-3 shadow-xl md:w-[800px]"
|
||||
>
|
||||
@@ -298,7 +318,7 @@
|
||||
size="icon"
|
||||
class="absolute right-[785px] top-8 z-10 h-8 w-8 rounded-md"
|
||||
on:click={(e) => {
|
||||
dispatch("closeModal", {})
|
||||
dispatch("closeModal", {});
|
||||
}}
|
||||
>
|
||||
<ChevronRight class="h-6 w-6 " />
|
||||
@@ -317,7 +337,7 @@
|
||||
class="peer sr-only"
|
||||
checked={newMonitor.status == "ACTIVE"}
|
||||
on:change={() => {
|
||||
newMonitor.status = newMonitor.status == "ACTIVE" ? "INACTIVE" : "ACTIVE"
|
||||
newMonitor.status = newMonitor.status == "ACTIVE" ? "INACTIVE" : "ACTIVE";
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
@@ -328,11 +348,10 @@
|
||||
</div>
|
||||
<div class="mt-12 w-full overflow-y-auto p-3" style="height: calc(100vh - 7rem);">
|
||||
<div class="grid grid-cols-3 gap-x-2 gap-y-4">
|
||||
<div class="col-span-3">
|
||||
<Label for="logo">Image</Label>
|
||||
<div class="col-span-3 flex gap-x-2">
|
||||
{#if !!newMonitor.image}
|
||||
<div class="relative mb-2 h-[48px] w-[48px] rounded-sm border p-1">
|
||||
<img src={base + newMonitor.image} class="" alt="" />
|
||||
<div class="relative mr-2 mt-3 h-[48px] w-[48px] rounded-sm border p-1">
|
||||
<GMI src={newMonitor.image} alt="" />
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="absolute -right-2.5 -top-1.5 h-5 w-5 rounded-full p-0"
|
||||
@@ -342,17 +361,57 @@
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<Input
|
||||
class="w-1/2"
|
||||
id="logo"
|
||||
type="file"
|
||||
disabled={loadingLogo}
|
||||
accept=".jpg, .jpeg, .png"
|
||||
on:change={(e) => {
|
||||
handleFileChangeLogo(e)
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Label for="typeOfLogoUpload" class="text-sm font-medium">Monitor Logo</Label>
|
||||
<Select.Root
|
||||
portal={null}
|
||||
onSelectedChange={(e) => {
|
||||
typeOfLogoUpload = e.value;
|
||||
}}
|
||||
selected={{
|
||||
value: typeOfLogoUpload,
|
||||
label: typeOfLogoUpload
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="w-[200px]" id="typeOfLogoUpload">
|
||||
<Select.Value bind:value={typeOfLogoUpload} placeholder="Logo Type" />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.Label>Logo Type</Select.Label>
|
||||
<Select.Item value="URL" label="URL" class="text-sm font-medium">URL</Select.Item>
|
||||
<Select.Item value="FILE" label="FILE" class="text-sm font-medium">FILE</Select.Item>
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{#if typeOfLogoUpload === "URL"}
|
||||
<div>
|
||||
<Label for="logo">Logo URL</Label>
|
||||
<Input
|
||||
bind:value={newMonitor.image}
|
||||
type="text"
|
||||
id="logo"
|
||||
placeholder="https://example.com/favicon.ico"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<Label for="logo" class="text-sm font-medium">Logo File (< 100 KB)</Label>
|
||||
<Input
|
||||
class="w-1/2"
|
||||
id="logo"
|
||||
type="file"
|
||||
disabled={loadingLogo}
|
||||
accept=".jpg, .jpeg, .png, .svg"
|
||||
on:change={(e) => {
|
||||
handleFileChangeLogo(e);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="col-span-1">
|
||||
<Label for="tag">
|
||||
Tag
|
||||
@@ -366,11 +425,7 @@
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<Label for="description">Description</Label>
|
||||
<Input
|
||||
bind:value={newMonitor.description}
|
||||
id="description"
|
||||
placeholder="Add a description for the monitor"
|
||||
/>
|
||||
<Input bind:value={newMonitor.description} id="description" placeholder="Add a description for the monitor" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-1">
|
||||
@@ -379,26 +434,17 @@
|
||||
</div>
|
||||
<div class="col-span-1">
|
||||
<Label for="default_status">Default Status</Label>
|
||||
<Select.Root
|
||||
portal={null}
|
||||
onSelectedChange={(e) => (newMonitor.default_status = e.value)}
|
||||
>
|
||||
<Select.Root portal={null} onSelectedChange={(e) => (newMonitor.default_status = e.value)}>
|
||||
<Select.Trigger id="default_status">
|
||||
<Select.Value placeholder={newMonitor.default_status} />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.Label>Status</Select.Label>
|
||||
<Select.Item value="NONE" label="NONE" class="text-sm font-medium">
|
||||
NONE
|
||||
</Select.Item>
|
||||
<Select.Item value="NONE" label="NONE" class="text-sm font-medium">NONE</Select.Item>
|
||||
<Select.Item value="UP" label="UP" class="text-sm font-medium">UP</Select.Item>
|
||||
<Select.Item value="DOWN" label="DOWN" class="text-sm font-medium">
|
||||
DOWN
|
||||
</Select.Item>
|
||||
<Select.Item value="DEGRADED" label="DEGRADED" class="text-sm font-medium">
|
||||
DEGRADED
|
||||
</Select.Item>
|
||||
<Select.Item value="DOWN" label="DOWN" class="text-sm font-medium">DOWN</Select.Item>
|
||||
<Select.Item value="DEGRADED" label="DEGRADED" class="text-sm font-medium">DEGRADED</Select.Item>
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
@@ -419,11 +465,7 @@
|
||||
<Select.Label>Category</Select.Label>
|
||||
|
||||
{#each categories as category}
|
||||
<Select.Item
|
||||
value={category.name}
|
||||
label={category.name}
|
||||
class="text-sm font-medium"
|
||||
>
|
||||
<Select.Item value={category.name} label={category.name} class="text-sm font-medium">
|
||||
{category.name}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
@@ -436,10 +478,7 @@
|
||||
<Label for="day_degraded_minimum_count">
|
||||
Day Degraded Minimum Count <span class="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
bind:value={newMonitor.day_degraded_minimum_count}
|
||||
id="day_degraded_minimum_count"
|
||||
/>
|
||||
<Input bind:value={newMonitor.day_degraded_minimum_count} id="day_degraded_minimum_count" />
|
||||
</div>
|
||||
<div class="col-span-1">
|
||||
<Label for="day_down_minimum_count">
|
||||
@@ -449,10 +488,7 @@
|
||||
</div>
|
||||
<div class="col-span-1">
|
||||
<Label for="include_degraded_in_downtime">Include Degraded In Downtime</Label>
|
||||
<Select.Root
|
||||
portal={null}
|
||||
onSelectedChange={(e) => (newMonitor.include_degraded_in_downtime = e.value)}
|
||||
>
|
||||
<Select.Root portal={null} onSelectedChange={(e) => (newMonitor.include_degraded_in_downtime = e.value)}>
|
||||
<Select.Trigger id="include_degraded_in_downtime">
|
||||
<Select.Value placeholder={newMonitor.include_degraded_in_downtime} />
|
||||
</Select.Trigger>
|
||||
@@ -481,15 +517,12 @@
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.Label>Type</Select.Label>
|
||||
<Select.Item value="NONE" label="NONE" class="text-sm font-medium">
|
||||
NONE
|
||||
</Select.Item>
|
||||
<Select.Item value="NONE" label="NONE" class="text-sm font-medium">NONE</Select.Item>
|
||||
<Select.Item value="API" label="API" class="text-sm font-medium">API</Select.Item>
|
||||
<Select.Item value="PING" label="PING" class="text-sm font-medium">
|
||||
PING
|
||||
</Select.Item>
|
||||
<Select.Item value="PING" label="PING" class="text-sm font-medium">PING</Select.Item>
|
||||
<Select.Item value="DNS" label="DNS" class="text-sm font-medium">DNS</Select.Item>
|
||||
<Select.Item value="TCP" label="TCP" class="text-sm font-medium">TCP</Select.Item>
|
||||
<Select.Item value="GROUP" label="GROUP" class="text-sm font-medium">GROUP</Select.Item>
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
@@ -506,10 +539,7 @@
|
||||
</div>
|
||||
<div class="col-span-1">
|
||||
<Label for="method">Method</Label>
|
||||
<Select.Root
|
||||
portal={null}
|
||||
onSelectedChange={(e) => (newMonitor.apiConfig.method = e.value)}
|
||||
>
|
||||
<Select.Root portal={null} onSelectedChange={(e) => (newMonitor.apiConfig.method = e.value)}>
|
||||
<Select.Trigger id="method">
|
||||
<Select.Value placeholder={newMonitor.apiConfig.method} />
|
||||
</Select.Trigger>
|
||||
@@ -517,27 +547,18 @@
|
||||
<Select.Group>
|
||||
<Select.Label>Method</Select.Label>
|
||||
<Select.Item value="GET" label="GET" class="text-sm font-medium">GET</Select.Item>
|
||||
<Select.Item value="POST" label="POST" class="text-sm font-medium">
|
||||
POST
|
||||
</Select.Item>
|
||||
<Select.Item value="POST" label="POST" class="text-sm font-medium">POST</Select.Item>
|
||||
<Select.Item value="PUT" label="PUT" class="text-sm font-medium">PUT</Select.Item>
|
||||
<Select.Item value="PATCH" label="PATCH" class="text-sm font-medium">
|
||||
PATCH
|
||||
</Select.Item>
|
||||
<Select.Item value="DELETE" label="DELETE" class="text-sm font-medium">
|
||||
DELETE
|
||||
</Select.Item>
|
||||
<Select.Item value="PATCH" label="PATCH" class="text-sm font-medium">PATCH</Select.Item>
|
||||
<Select.Item value="DELETE" label="DELETE" class="text-sm font-medium">DELETE</Select.Item>
|
||||
<Select.Item value="HEAD" label="HEAD" class="text-sm font-medium">HEAD</Select.Item>
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="col-span-4">
|
||||
<Label for="url">URL <span class="text-red-500">*</span></Label>
|
||||
<Input
|
||||
bind:value={newMonitor.apiConfig.url}
|
||||
id="url"
|
||||
placeholder="https://example.com/api/users"
|
||||
/>
|
||||
<Input bind:value={newMonitor.apiConfig.url} id="url" placeholder="https://example.com/api/users" />
|
||||
</div>
|
||||
<div class="col-span-6 mt-2">
|
||||
<Label for="url">Headers</Label>
|
||||
@@ -554,9 +575,7 @@
|
||||
class=" h-6 w-6 p-1"
|
||||
variant="secondary"
|
||||
on:click={() => {
|
||||
newMonitor.apiConfig.headers = newMonitor.apiConfig.headers.filter(
|
||||
(_, i) => i !== index
|
||||
)
|
||||
newMonitor.apiConfig.headers = newMonitor.apiConfig.headers.filter((_, i) => i !== index);
|
||||
}}
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
@@ -591,8 +610,8 @@
|
||||
<div class="col-span-6">
|
||||
<Label for="eval">Eval</Label>
|
||||
<p class="my-1 text-xs text-muted-foreground">
|
||||
You can write a custom eval function to evaluate the response. The function should
|
||||
return a promise that resolves to an object with status and latency. <a
|
||||
You can write a custom eval function to evaluate the response. The function should return a promise that
|
||||
resolves to an object with status and latency. <a
|
||||
target="_blank"
|
||||
class="font-medium text-primary"
|
||||
href="https://kener.ing/docs/monitors-api#eval">Read the docs</a
|
||||
@@ -628,15 +647,9 @@
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.Label>Type</Select.Label>
|
||||
<Select.Item value="IP4" label="IP4" class="text-sm font-medium">
|
||||
IP4
|
||||
</Select.Item>
|
||||
<Select.Item value="IP6" label="IP6" class="text-sm font-medium">
|
||||
IP6
|
||||
</Select.Item>
|
||||
<Select.Item value="DOMAIN" label="DOMAIN" class="text-sm font-medium">
|
||||
DOMAIN
|
||||
</Select.Item>
|
||||
<Select.Item value="IP4" label="IP4" class="text-sm font-medium">IP4</Select.Item>
|
||||
<Select.Item value="IP6" label="IP6" class="text-sm font-medium">IP6</Select.Item>
|
||||
<Select.Item value="DOMAIN" label="DOMAIN" class="text-sm font-medium">DOMAIN</Select.Item>
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
@@ -656,20 +669,14 @@
|
||||
</div>
|
||||
<div>
|
||||
<Label for="hostsV4count">Count</Label>
|
||||
<Input
|
||||
bind:value={host.count}
|
||||
id="hostsV4count"
|
||||
placeholder="number of pings ex 4"
|
||||
/>
|
||||
<Input bind:value={host.count} id="hostsV4count" placeholder="number of pings ex 4" />
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
class=" right-3 top-2 mt-8 h-6 w-6 p-1"
|
||||
variant="secondary"
|
||||
on:click={() => {
|
||||
newMonitor.pingConfig.hosts = newMonitor.pingConfig.hosts.filter(
|
||||
(_, i) => i !== index
|
||||
)
|
||||
newMonitor.pingConfig.hosts = newMonitor.pingConfig.hosts.filter((_, i) => i !== index);
|
||||
}}
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
@@ -688,7 +695,7 @@
|
||||
newMonitor.pingConfig.hosts = [
|
||||
...newMonitor.pingConfig.hosts,
|
||||
{ host: "", timeout: 3000, count: 4, type: "IP4" }
|
||||
]
|
||||
];
|
||||
}}
|
||||
>
|
||||
<Plus class="h-5 w-5" /> Add New
|
||||
@@ -698,8 +705,8 @@
|
||||
<div class="mt-2">
|
||||
<Label for="pingEval">PING Eval</Label>
|
||||
<p class="my-1 text-xs text-muted-foreground">
|
||||
You can write a custom eval function to evaluate the response. The function should
|
||||
return a promise that resolves to an object with status and latency. <a
|
||||
You can write a custom eval function to evaluate the response. The function should return a promise that
|
||||
resolves to an object with status and latency. <a
|
||||
target="_blank"
|
||||
class="font-medium text-primary"
|
||||
href="https://kener.ing/docs/monitors-ping#eval">Read the docs</a
|
||||
@@ -736,15 +743,9 @@
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.Label>Type</Select.Label>
|
||||
<Select.Item value="IP4" label="IP4" class="text-sm font-medium">
|
||||
IP4
|
||||
</Select.Item>
|
||||
<Select.Item value="IP6" label="IP6" class="text-sm font-medium">
|
||||
IP6
|
||||
</Select.Item>
|
||||
<Select.Item value="DOMAIN" label="DOMAIN" class="text-sm font-medium">
|
||||
DOMAIN
|
||||
</Select.Item>
|
||||
<Select.Item value="IP4" label="IP4" class="text-sm font-medium">IP4</Select.Item>
|
||||
<Select.Item value="IP6" label="IP6" class="text-sm font-medium">IP6</Select.Item>
|
||||
<Select.Item value="DOMAIN" label="DOMAIN" class="text-sm font-medium">DOMAIN</Select.Item>
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
@@ -755,28 +756,18 @@
|
||||
</div>
|
||||
<div>
|
||||
<Label for="hostsV4port">Port</Label>
|
||||
<Input
|
||||
bind:value={host.port}
|
||||
id="hostsV4port"
|
||||
placeholder="port number ex 8080"
|
||||
/>
|
||||
<Input bind:value={host.port} id="hostsV4port" placeholder="port number ex 8080" />
|
||||
</div>
|
||||
<div>
|
||||
<Label for="hostsV4timeout">Timeout</Label>
|
||||
<Input
|
||||
bind:value={host.timeout}
|
||||
id="hostsV4timeout"
|
||||
placeholder="timeout in ms ex 3000"
|
||||
/>
|
||||
<Input bind:value={host.timeout} id="hostsV4timeout" placeholder="timeout in ms ex 3000" />
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
class="right-3 top-2 mt-8 h-6 w-6 p-1"
|
||||
variant="secondary"
|
||||
on:click={() => {
|
||||
newMonitor.tcpConfig.hosts = newMonitor.tcpConfig.hosts.filter(
|
||||
(_, i) => i !== index
|
||||
)
|
||||
newMonitor.tcpConfig.hosts = newMonitor.tcpConfig.hosts.filter((_, i) => i !== index);
|
||||
}}
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
@@ -795,7 +786,7 @@
|
||||
newMonitor.tcpConfig.hosts = [
|
||||
...newMonitor.tcpConfig.hosts,
|
||||
{ host: "", timeout: 3000, type: "IP4", port: "" }
|
||||
]
|
||||
];
|
||||
}}
|
||||
>
|
||||
<Plus class="h-5 w-5" /> Add New
|
||||
@@ -805,8 +796,8 @@
|
||||
<div class="mt-2">
|
||||
<Label for="tcpEval">TCP Eval</Label>
|
||||
<p class="my-1 text-xs text-muted-foreground">
|
||||
You can write a custom eval function to evaluate the response. The function should
|
||||
return a promise that resolves to an object with status and latency. <a
|
||||
You can write a custom eval function to evaluate the response. The function should return a promise that
|
||||
resolves to an object with status and latency. <a
|
||||
target="_blank"
|
||||
class="font-medium text-primary"
|
||||
href="https://kener.ing/docs/monitors-tcp#eval">Read the docs</a
|
||||
@@ -829,10 +820,7 @@
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<Label for="lookupRecord">Lookup Record</Label>
|
||||
<Select.Root
|
||||
portal={null}
|
||||
onSelectedChange={(e) => (newMonitor.dnsConfig.lookupRecord = e.value)}
|
||||
>
|
||||
<Select.Root portal={null} onSelectedChange={(e) => (newMonitor.dnsConfig.lookupRecord = e.value)}>
|
||||
<Select.Trigger id="lookupRecord">
|
||||
<Select.Value placeholder={newMonitor.dnsConfig.lookupRecord} />
|
||||
</Select.Trigger>
|
||||
@@ -854,10 +842,7 @@
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<Label for="matchType">Match Type</Label>
|
||||
<Select.Root
|
||||
portal={null}
|
||||
onSelectedChange={(e) => (newMonitor.dnsConfig.matchType = e.value)}
|
||||
>
|
||||
<Select.Root portal={null} onSelectedChange={(e) => (newMonitor.dnsConfig.matchType = e.value)}>
|
||||
<Select.Trigger id="matchType">
|
||||
<Select.Value placeholder={newMonitor.dnsConfig.matchType} />
|
||||
</Select.Trigger>
|
||||
@@ -881,9 +866,7 @@
|
||||
class="absolute right-3 top-2 h-6 w-6 p-1"
|
||||
variant="secondary"
|
||||
on:click={() => {
|
||||
newMonitor.dnsConfig.values = newMonitor.dnsConfig.values.filter(
|
||||
(_, i) => i !== index
|
||||
)
|
||||
newMonitor.dnsConfig.values = newMonitor.dnsConfig.values.filter((_, i) => i !== index);
|
||||
}}
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
@@ -895,7 +878,7 @@
|
||||
class="h-6 w-6 p-1"
|
||||
variant="secondary"
|
||||
on:click={() => {
|
||||
newMonitor.dnsConfig.values = [...newMonitor.dnsConfig.values, ""]
|
||||
newMonitor.dnsConfig.values = [...newMonitor.dnsConfig.values, ""];
|
||||
}}
|
||||
>
|
||||
<Plus class="h-5 w-5" />
|
||||
@@ -904,6 +887,54 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if newMonitor.monitor_type == "GROUP"}
|
||||
<div class="mt-4 grid grid-cols-6 gap-2">
|
||||
<div class="col-span-6 mb-1">
|
||||
<Label for="timeout">
|
||||
Timeout(ms)
|
||||
<span class="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input bind:value={newMonitor.groupConfig.timeout} class="w-40" id="timeout" />
|
||||
<p class="my-1 text-xs text-muted-foreground">
|
||||
Maximum Time in milliseconds it will wait for all the monitors to resolved in that particular timestamp
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-span-6">
|
||||
<Label class="text-sm">Monitors</Label>
|
||||
</div>
|
||||
{#each newMonitor.groupConfig.monitors as monitor}
|
||||
<div class="col-span-3">
|
||||
<label class="cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
on:change={(e) => {
|
||||
monitor.selected = e.target.checked;
|
||||
}}
|
||||
checked={monitor.selected}
|
||||
/>
|
||||
{monitor.name}
|
||||
<span class="text-muted-foreground">({monitor.tag})</span>
|
||||
</label>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="col-span-6 mt-2 rounded-sm border p-2">
|
||||
<label class="cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
on:change={(e) => {
|
||||
newMonitor.groupConfig.hideMonitors = e.target.checked;
|
||||
}}
|
||||
checked={newMonitor.groupConfig.hideMonitors}
|
||||
/>
|
||||
Hide Grouped Monitors from Home View
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-span-6 mt-2">
|
||||
<ul class="text-xs font-medium leading-5 text-muted-foreground">
|
||||
<li>- The group status will be the worst status of the monitors in the group.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="absolute bottom-0 grid h-16 w-full grid-cols-6 justify-end gap-2 border-t p-3 pr-6">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Plus, X, Settings, Bell, Loader, ArrowDownUp, Grip } from "lucide-svelte";
|
||||
import { Plus, X, Settings, Bell, Loader, ArrowDownUp, Grip, ExternalLink } from "lucide-svelte";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { base } from "$app/paths";
|
||||
@@ -9,8 +9,10 @@
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { storeSiteData, SortMonitor } from "$lib/clientTools.js";
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
||||
import { dndzone } from "svelte-dnd-action";
|
||||
import { flip } from "svelte/animate";
|
||||
import GMI from "$lib/components/gmi.svelte";
|
||||
|
||||
export let categories = [];
|
||||
export let colorDown = "#777";
|
||||
@@ -30,9 +32,14 @@
|
||||
//broadcast a custom event named blockScroll
|
||||
if (!!isMounted) {
|
||||
const noScrollEvent = new CustomEvent("noScroll", {
|
||||
detail: showAddMonitor
|
||||
detail: showAddMonitor || draggableMenu || shareMenusToggle
|
||||
});
|
||||
window.dispatchEvent(noScrollEvent);
|
||||
|
||||
//if modal closed then clear url hashed
|
||||
if (!showAddMonitor) {
|
||||
window.location.hash = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,10 +87,29 @@
|
||||
nameServer: "8.8.8.8",
|
||||
matchType: "ANY",
|
||||
values: []
|
||||
}
|
||||
},
|
||||
groupConfig: createGroupConfig({
|
||||
monitors: [],
|
||||
timeout: 10000,
|
||||
hideMonitors: false
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function createGroupConfig(groupConfig) {
|
||||
let eligibleMonitors = monitors
|
||||
.filter((m) => m.monitor_type != "GROUP" && m.status == "ACTIVE")
|
||||
.map((m) => {
|
||||
let isSelected = false;
|
||||
if (groupConfig.monitors) {
|
||||
isSelected = !!groupConfig.monitors.find((tm) => tm.id == m.id);
|
||||
}
|
||||
|
||||
return { id: m.id, name: m.name, tag: m.tag, selected: isSelected };
|
||||
});
|
||||
return { monitors: eligibleMonitors, timeout: groupConfig.timeout, hideMonitors: groupConfig.hideMonitors };
|
||||
}
|
||||
|
||||
function showUpdateMonitorSheet(m) {
|
||||
resetNewMonitor();
|
||||
newMonitor = { ...newMonitor, ...m };
|
||||
@@ -96,6 +122,8 @@
|
||||
newMonitor.dnsConfig = JSON.parse(newMonitor.type_data);
|
||||
} else if (newMonitor.monitor_type == "TCP") {
|
||||
newMonitor.tcpConfig = JSON.parse(newMonitor.type_data);
|
||||
} else if (newMonitor.monitor_type == "GROUP") {
|
||||
newMonitor.groupConfig = createGroupConfig(JSON.parse(newMonitor.type_data));
|
||||
}
|
||||
showAddMonitor = true;
|
||||
}
|
||||
@@ -135,7 +163,7 @@
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ action: "getTriggers", data: { status: "ACTIVE" } })
|
||||
body: JSON.stringify({ action: "getTriggers", data: {} })
|
||||
});
|
||||
triggers = await apiResp.json();
|
||||
} catch (error) {
|
||||
@@ -239,6 +267,32 @@
|
||||
}
|
||||
let dropTargetStyle;
|
||||
let draggableMenu = false;
|
||||
|
||||
function testMonitor(i) {
|
||||
if (monitors[i].isTestRunning) {
|
||||
return;
|
||||
}
|
||||
monitors[i].isTestRunning = true;
|
||||
fetch(base + "/manage/app/api/", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: "testMonitor",
|
||||
data: { monitor_id: monitors[i].id }
|
||||
})
|
||||
})
|
||||
.then((resp) => resp.json())
|
||||
.then((data) => {
|
||||
monitors[i].isTestRunning = false;
|
||||
monitors[i].testResult = data;
|
||||
})
|
||||
.catch((error) => {
|
||||
monitors[i].isTestRunning = false;
|
||||
monitors[i].testResult = data;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showAddMonitor}
|
||||
@@ -252,9 +306,7 @@
|
||||
/>
|
||||
{/if}
|
||||
{#if draggableMenu}
|
||||
<div
|
||||
class="moldal-container fixed left-0 top-0 z-50 h-screen w-full bg-card bg-opacity-30 backdrop-blur-sm"
|
||||
>
|
||||
<div class="moldal-container fixed left-0 top-0 z-50 h-screen w-full bg-card bg-opacity-30 backdrop-blur-sm">
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-fit w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-md border bg-background shadow-lg backdrop-blur-lg"
|
||||
>
|
||||
@@ -279,11 +331,7 @@
|
||||
<div animate:flip={{ duration: flipDurationMs }} class="mb-2 rounded-md bg-card p-2">
|
||||
<Grip class="mr-2 inline h-4 w-4" />
|
||||
{#if !!monitor.image}
|
||||
<img
|
||||
src={base + monitor.image}
|
||||
alt={monitor.name}
|
||||
class="mr-1 inline-block h-4 w-4"
|
||||
/>
|
||||
<GMI src={monitor.image} alt={monitor.name} classList="mr-1 inline-block h-4 w-4" />
|
||||
{/if}
|
||||
{monitor.name}
|
||||
</div>
|
||||
@@ -312,12 +360,8 @@
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.Label>Status</Select.Label>
|
||||
<Select.Item value="ACTIVE" label="ACTIVE" class="text-sm font-medium">
|
||||
ACTIVE
|
||||
</Select.Item>
|
||||
<Select.Item value="INACTIVE" label="INACTIVE" class="text-sm font-medium">
|
||||
INACTIVE
|
||||
</Select.Item>
|
||||
<Select.Item value="ACTIVE" label="ACTIVE" class="text-sm font-medium">ACTIVE</Select.Item>
|
||||
<Select.Item value="INACTIVE" label="INACTIVE" class="text-sm font-medium">INACTIVE</Select.Item>
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
@@ -370,22 +414,64 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
{#each monitors as monitor}
|
||||
{#each monitors as monitor, i}
|
||||
<Card.Root class="mb-4">
|
||||
<Card.Header class="relative">
|
||||
<Card.Title>
|
||||
{#if !!monitor.image}
|
||||
<img src={base + monitor.image} alt={monitor.name} class="mr-2 inline-block h-8 w-8" />
|
||||
<GMI src={monitor.image} alt={monitor.name} classList="mr-2 inline-block h-8 w-8" />
|
||||
{/if}
|
||||
{monitor.name}
|
||||
</Card.Title>
|
||||
{#if !!monitor.description}
|
||||
<Card.Description>{@html monitor.description}</Card.Description>
|
||||
{/if}
|
||||
<div class="absolute right-2 top-0.5">
|
||||
<Button variant="secondary" class="h-8 w-8 p-2" on:click={() => openAlertMenu(monitor)}>
|
||||
<div class="absolute right-2 top-0.5 flex gap-x-1">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="secondary" class="h-8 p-2 text-xs" on:click={() => testMonitor(i)}>TEST</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content class="max-w-md">
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.Label class="text-xs">Test Result</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
<div class="px-2 text-xs">
|
||||
{#if monitor.isTestRunning}
|
||||
<div class="text-center">
|
||||
<Loader class="mx-auto inline h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
{:else if !!monitor.testResult}
|
||||
{#if !!monitor.testResult.error}
|
||||
<div class="text-red-500">
|
||||
{monitor.testResult.error}
|
||||
</div>
|
||||
{:else if !!monitor.testResult.status && !!monitor.testResult.latency}
|
||||
<p class="text-muted-foreground">
|
||||
Status: <span class="text-api-{monitor.testResult.status.toLowerCase()}"
|
||||
>{monitor.testResult.status}</span
|
||||
><br /> Response Time: <span class="text-card-foreground">{monitor.testResult.latency}ms</span>
|
||||
</p>
|
||||
{:else}
|
||||
<div class="text-muted-foreground">No Test Result</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="h-8 w-8 p-2 {monitor.down_trigger?.active || monitor.degraded_trigger?.active
|
||||
? 'text-yellow-500'
|
||||
: ''}"
|
||||
on:click={() => openAlertMenu(monitor)}
|
||||
>
|
||||
<Bell class="inline h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="secondary" class="h-8 w-8 p-2" rel="external" href="{base}/?monitor={monitor.tag}">
|
||||
<ExternalLink class="inline h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="secondary" class="h-8 w-8 p-2" href="#{monitor.tag}">
|
||||
<Settings class="inline h-4 w-4" />
|
||||
</Button>
|
||||
@@ -424,9 +510,7 @@
|
||||
</div>
|
||||
|
||||
{#if shareMenusToggle}
|
||||
<div
|
||||
class="moldal-container fixed left-0 top-0 z-50 h-screen w-full bg-card bg-opacity-30 backdrop-blur-sm"
|
||||
>
|
||||
<div class="moldal-container fixed left-0 top-0 z-50 h-screen w-full bg-card bg-opacity-30 backdrop-blur-sm">
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-fit w-full max-w-2xl -translate-x-1/2 -translate-y-1/2 rounded-md border bg-background shadow-lg backdrop-blur-lg"
|
||||
>
|
||||
@@ -449,10 +533,7 @@
|
||||
<hr class="my-4" />
|
||||
{#each Object.entries(monitorTriggers) as [key, data]}
|
||||
<div class="flex justify-between">
|
||||
<h3
|
||||
class="font-semibold"
|
||||
style="color:{data.trigger_type == 'DOWN' ? colorDown : colorDegraded};"
|
||||
>
|
||||
<h3 class="font-semibold" style="color:{data.trigger_type == 'DOWN' ? colorDown : colorDegraded};">
|
||||
If Monitor {data.trigger_type}
|
||||
</h3>
|
||||
<div>
|
||||
@@ -479,12 +560,7 @@
|
||||
Failure Threshold
|
||||
<span class="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
bind:value={data.failureThreshold}
|
||||
min="1"
|
||||
id="{key}failureThreshold"
|
||||
type="number"
|
||||
/>
|
||||
<Input bind:value={data.failureThreshold} min="1" id="{key}failureThreshold" type="number" />
|
||||
</div>
|
||||
<div class="col-span-1">
|
||||
<div class="col-span-1">
|
||||
@@ -492,12 +568,7 @@
|
||||
Success Threshold
|
||||
<span class="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
bind:value={data.successThreshold}
|
||||
min="1"
|
||||
id="{key}successThreshold"
|
||||
type="number"
|
||||
/>
|
||||
<Input bind:value={data.successThreshold} min="1" id="{key}successThreshold" type="number" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-1">
|
||||
@@ -511,16 +582,11 @@
|
||||
}}
|
||||
>
|
||||
<Select.Trigger id="{key}createIncident">
|
||||
<Select.Value
|
||||
bind:value={data.createIncident}
|
||||
placeholder={data.createIncident}
|
||||
/>
|
||||
<Select.Value bind:value={data.createIncident} placeholder={data.createIncident} />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.Item value="YES" label="YES" class="text-sm font-medium">
|
||||
YES
|
||||
</Select.Item>
|
||||
<Select.Item value="YES" label="YES" class="text-sm font-medium">YES</Select.Item>
|
||||
<Select.Item value="NO" label="NO" class="text-sm font-medium">NO</Select.Item>
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
@@ -544,12 +610,8 @@
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.Item value="critical" label="CRITICAL" class="text-sm font-medium">
|
||||
CRITICAL
|
||||
</Select.Item>
|
||||
<Select.Item value="warning" label="WARNING" class="text-sm font-medium">
|
||||
WARNING
|
||||
</Select.Item>
|
||||
<Select.Item value="critical" label="CRITICAL" class="text-sm font-medium">CRITICAL</Select.Item>
|
||||
<Select.Item value="warning" label="WARNING" class="text-sm font-medium">WARNING</Select.Item>
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
@@ -564,7 +626,7 @@
|
||||
<p class="col-span-4 mt-2 text-sm font-medium">Choose Triggers</p>
|
||||
{#each triggers as trigger}
|
||||
<div class="col-span-1 overflow-hidden overflow-ellipsis whitespace-nowrap">
|
||||
<label class="cursor-pointer">
|
||||
<label class="cursor-pointer" class:line-through={trigger.trigger_status != "ACTIVE"}>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="text-sm"
|
||||
@@ -576,29 +638,13 @@
|
||||
}}
|
||||
/>
|
||||
{#if trigger.trigger_type == "webhook"}
|
||||
<img
|
||||
src={base + "/webhooks.svg"}
|
||||
alt={trigger.trigger_type}
|
||||
class="ml-2 inline-block h-4 w-4"
|
||||
/>
|
||||
<img src={base + "/webhooks.svg"} alt={trigger.trigger_type} class="ml-2 inline-block h-4 w-4" />
|
||||
{:else if trigger.trigger_type == "email"}
|
||||
<img
|
||||
src={base + "/email.png"}
|
||||
alt={trigger.trigger_type}
|
||||
class="ml-2 inline-block h-4 w-4"
|
||||
/>
|
||||
<img src={base + "/email.png"} alt={trigger.trigger_type} class="ml-2 inline-block h-4 w-4" />
|
||||
{:else if trigger.trigger_type == "slack"}
|
||||
<img
|
||||
src={base + "/slack.svg"}
|
||||
alt={trigger.trigger_type}
|
||||
class="ml-2 inline-block h-4 w-4"
|
||||
/>
|
||||
<img src={base + "/slack.svg"} alt={trigger.trigger_type} class="ml-2 inline-block h-4 w-4" />
|
||||
{:else if trigger.trigger_type == "discord"}
|
||||
<img
|
||||
src={base + "/discord.svg"}
|
||||
alt={trigger.trigger_type}
|
||||
class="ml-2 inline-block h-4 w-4"
|
||||
/>
|
||||
<img src={base + "/discord.svg"} alt={trigger.trigger_type} class="ml-2 inline-block h-4 w-4" />
|
||||
{/if}
|
||||
{trigger.name}
|
||||
</label>
|
||||
|
||||
@@ -1,381 +1,452 @@
|
||||
<script>
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { siteDataExtractFromDb, storeSiteData } from "$lib/clientTools.js";
|
||||
import { tooltipAction } from "svelte-legos";
|
||||
import { base } from "$app/paths";
|
||||
import { Loader, Info, X } from "lucide-svelte";
|
||||
import { Tooltip } from "bits-ui";
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { siteDataExtractFromDb, storeSiteData } from "$lib/clientTools.js";
|
||||
import { tooltipAction } from "svelte-legos";
|
||||
import { base } from "$app/paths";
|
||||
import { Loader, Info, X } from "lucide-svelte";
|
||||
import * as Alert from "$lib/components/ui/alert";
|
||||
import { Tooltip } from "bits-ui";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import GMI from "$lib/components/gmi.svelte";
|
||||
|
||||
export let data;
|
||||
export let data;
|
||||
|
||||
let siteInformation = {
|
||||
title: "",
|
||||
siteName: "",
|
||||
siteURL: "",
|
||||
home: "",
|
||||
logo: "",
|
||||
favicon: ""
|
||||
};
|
||||
let siteInformation = {
|
||||
title: "",
|
||||
siteName: "",
|
||||
siteURL: "",
|
||||
home: "",
|
||||
logo: "",
|
||||
favicon: ""
|
||||
};
|
||||
|
||||
let logoFile;
|
||||
let logoFile;
|
||||
|
||||
let formErrorMessage = "";
|
||||
let formErrorMessage = "";
|
||||
|
||||
siteInformation = siteDataExtractFromDb(data.siteData, siteInformation);
|
||||
siteInformation = siteDataExtractFromDb(data.siteData, siteInformation);
|
||||
|
||||
let formState = "idle";
|
||||
async function formSubmit() {
|
||||
formErrorMessage = "";
|
||||
formState = "loading";
|
||||
let resp = await storeSiteData(siteInformation);
|
||||
//print data
|
||||
let data = await resp.json();
|
||||
formState = "idle";
|
||||
if (data.error) {
|
||||
formErrorMessage = data.error;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let formState = "idle";
|
||||
async function formSubmit() {
|
||||
formErrorMessage = "";
|
||||
formState = "loading";
|
||||
let resp = await storeSiteData(siteInformation);
|
||||
//print data
|
||||
let data = await resp.json();
|
||||
formState = "idle";
|
||||
if (data.error) {
|
||||
formErrorMessage = data.error;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileChangeFavicon(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
siteInformation.favicon = reader.result;
|
||||
uploadFunction(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
let uploadLogoStatus = "";
|
||||
let uploadingLogo = false;
|
||||
async function handleFileChangeLogo(event) {
|
||||
event.preventDefault();
|
||||
function handleFileChangeFavicon(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
siteInformation.favicon = reader.result;
|
||||
uploadFunction(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
let uploadLogoStatus = "";
|
||||
let uploadingLogo = false;
|
||||
async function handleFileChangeLogo(event) {
|
||||
event.preventDefault();
|
||||
|
||||
uploadLogoStatus = "";
|
||||
const file = event.target.files[0];
|
||||
if (!file) {
|
||||
uploadLogoStatus = "Please select a file to upload.";
|
||||
return;
|
||||
}
|
||||
uploadLogoStatus = "";
|
||||
const file = event.target.files[0];
|
||||
if (!file) {
|
||||
uploadLogoStatus = "Please select a file to upload.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 100000) {
|
||||
uploadLogoStatus = "File size should be less than 100KB.";
|
||||
return;
|
||||
}
|
||||
if (file.size > 102400) {
|
||||
uploadLogoStatus = "File size should be less than 100KB.";
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
uploadingLogo = true;
|
||||
try {
|
||||
const response = await fetch(base + "/manage/app/upload", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
});
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
uploadingLogo = true;
|
||||
try {
|
||||
const response = await fetch(base + "/manage/app/upload", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
siteInformation.logo = "/uploads/" + result.filename;
|
||||
} else {
|
||||
uploadLogoStatus = "Failed to upload file.";
|
||||
}
|
||||
} catch (error) {
|
||||
uploadLogoStatus = "An error occurred while uploading the file.";
|
||||
} finally {
|
||||
uploadingLogo = false;
|
||||
}
|
||||
}
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
siteInformation.logo = "/uploads/" + result.filename;
|
||||
} else {
|
||||
uploadLogoStatus = "Failed to upload file.";
|
||||
}
|
||||
} catch (error) {
|
||||
uploadLogoStatus = "An error occurred while uploading the file.";
|
||||
} finally {
|
||||
uploadingLogo = false;
|
||||
}
|
||||
}
|
||||
|
||||
let uploadFaviconStatus = "";
|
||||
let uploadingFavicon = false;
|
||||
async function handleFaviconChangeLogo(event) {
|
||||
event.preventDefault();
|
||||
let uploadFaviconStatus = "";
|
||||
let uploadingFavicon = false;
|
||||
async function handleFaviconChangeLogo(event) {
|
||||
event.preventDefault();
|
||||
|
||||
uploadFaviconStatus = "";
|
||||
const file = event.target.files[0];
|
||||
if (!file) {
|
||||
uploadFaviconStatus = "Please select a file to upload.";
|
||||
return;
|
||||
}
|
||||
uploadFaviconStatus = "";
|
||||
const file = event.target.files[0];
|
||||
if (!file) {
|
||||
uploadFaviconStatus = "Please select a file to upload.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 20000) {
|
||||
uploadFaviconStatus = "File size should be less than 20KB.";
|
||||
return;
|
||||
}
|
||||
if (file.size > 20480) {
|
||||
uploadFaviconStatus = "File size should be less than 20KB.";
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
uploadingFavicon = true;
|
||||
try {
|
||||
const response = await fetch(base + "/manage/app/upload", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
});
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
uploadingFavicon = true;
|
||||
try {
|
||||
const response = await fetch(base + "/manage/app/upload", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
siteInformation.favicon = "/uploads/" + result.filename;
|
||||
} else {
|
||||
uploadFaviconStatus = "Failed to upload file.";
|
||||
}
|
||||
} catch (error) {
|
||||
uploadFaviconStatus = "An error occurred while uploading the file.";
|
||||
} finally {
|
||||
uploadingFavicon = false;
|
||||
}
|
||||
}
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
siteInformation.favicon = "/uploads/" + result.filename;
|
||||
} else {
|
||||
uploadFaviconStatus = "Failed to upload file.";
|
||||
}
|
||||
} catch (error) {
|
||||
uploadFaviconStatus = "An error occurred while uploading the file.";
|
||||
} finally {
|
||||
uploadingFavicon = false;
|
||||
}
|
||||
}
|
||||
let typeOfLogoUpload = siteInformation.logo.startsWith("http") ? "URL" : "FILE";
|
||||
let typeOfFaviconUpload = siteInformation.favicon.startsWith("http") ? "URL" : "FILE";
|
||||
</script>
|
||||
|
||||
<Card.Root class="mt-4">
|
||||
<Card.Header class="border-b">
|
||||
<Card.Title>Site Configuration</Card.Title>
|
||||
<Card.Description>Configure your site information here.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form class="mx-auto mt-4 space-y-4" on:submit|preventDefault={formSubmit}>
|
||||
<div class="flex w-full flex-row justify-evenly gap-2">
|
||||
<div class="w-full">
|
||||
<Label for="title" class="">
|
||||
Site Title
|
||||
<span class="text-red-500">*</span>
|
||||
<Card.Header class="border-b">
|
||||
<Card.Title>Site Configuration</Card.Title>
|
||||
<Card.Description>Configure your site information here.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if !!base}
|
||||
<Alert.Root class="my-2">
|
||||
<Alert.Title>Sub path</Alert.Title>
|
||||
<Alert.Description>Your site is running in a subpath <span class="">{base}</span></Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
<form class="mx-auto mt-4 space-y-4" on:submit|preventDefault={formSubmit}>
|
||||
<div class="flex w-full flex-row justify-evenly gap-2">
|
||||
<div class="w-full">
|
||||
<Label for="title" class="">
|
||||
Site Title
|
||||
<span class="text-red-500">*</span>
|
||||
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger class="">
|
||||
<Info class="inline-block h-4 w-4 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<div
|
||||
class=" flex items-center justify-center rounded border bg-input p-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
|
||||
>
|
||||
Site Title will be the page title of your site.
|
||||
<title></title>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Label>
|
||||
<Input
|
||||
bind:value={siteInformation.title}
|
||||
class="mt-2"
|
||||
type="text"
|
||||
id="title"
|
||||
placeholder="eg. kener"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<Label for="siteName">
|
||||
Site Name
|
||||
<span class="text-red-500">*</span>
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger class="">
|
||||
<Info class="inline-block h-4 w-4 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<div
|
||||
class=" flex items-center justify-center rounded border bg-input p-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
|
||||
>
|
||||
Site Title will be the page title of your site. <title></title>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Label>
|
||||
<Input
|
||||
bind:value={siteInformation.title}
|
||||
class="mt-2"
|
||||
type="text"
|
||||
id="title"
|
||||
placeholder="eg. kener"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<Label for="siteName">
|
||||
Site Name
|
||||
<span class="text-red-500">*</span>
|
||||
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger class="">
|
||||
<Info class="inline-block h-4 w-4 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<div
|
||||
class=" flex items-center justify-center rounded border bg-input p-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
|
||||
>
|
||||
This is the name of your site.<br /> It will be shown in the nav
|
||||
bar as brand name.
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Label>
|
||||
<Input
|
||||
bind:value={siteInformation.siteName}
|
||||
class="mt-2"
|
||||
type="text"
|
||||
id="siteName"
|
||||
placeholder="eg. Kener.ing"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-row justify-evenly gap-2">
|
||||
<div class="w-full">
|
||||
<Label for="siteURL">
|
||||
Site URL
|
||||
<span class="text-red-500">*</span>
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger class="">
|
||||
<Info class="inline-block h-4 w-4 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<div
|
||||
class=" flex items-center justify-center rounded border bg-input p-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
|
||||
>
|
||||
The URL your kener is hosted on.
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Label>
|
||||
<Input
|
||||
bind:value={siteInformation.siteURL}
|
||||
class="mt-2"
|
||||
type="text"
|
||||
id="siteURL"
|
||||
placeholder="eg. https://status.example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-row justify-evenly gap-2">
|
||||
<div class="w-full">
|
||||
<Label for="siteURL">
|
||||
Home Location
|
||||
<span class="text-red-500">*</span>
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger class="">
|
||||
<Info class="inline-block h-4 w-4 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<div
|
||||
class=" flex items-center justify-center rounded border bg-input p-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
|
||||
>
|
||||
Where to go when someone clicks on the brand logo in the navbar.
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Label>
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger class="">
|
||||
<Info class="inline-block h-4 w-4 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<div
|
||||
class=" flex items-center justify-center rounded border bg-input p-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
|
||||
>
|
||||
This is the name of your site.<br /> It will be shown in the nav bar as brand name.
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Label>
|
||||
<Input
|
||||
bind:value={siteInformation.siteName}
|
||||
class="mt-2"
|
||||
type="text"
|
||||
id="siteName"
|
||||
placeholder="eg. Kener.ing"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-row justify-evenly gap-2">
|
||||
<div class="w-full">
|
||||
<Label for="siteURL">
|
||||
Site URL
|
||||
<span class="text-red-500">*</span>
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger class="">
|
||||
<Info class="inline-block h-4 w-4 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<div
|
||||
class=" flex items-center justify-center rounded border bg-input p-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
|
||||
>
|
||||
The URL your kener is hosted on.
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Label>
|
||||
<Input
|
||||
bind:value={siteInformation.siteURL}
|
||||
class="mt-2"
|
||||
type="text"
|
||||
id="siteURL"
|
||||
placeholder="eg. https://status.example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-row justify-evenly gap-2">
|
||||
<div class="w-full">
|
||||
<Label for="siteURL">
|
||||
Home Location
|
||||
<span class="text-red-500">*</span>
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger class="">
|
||||
<Info class="inline-block h-4 w-4 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<div
|
||||
class=" flex items-center justify-center rounded border bg-input p-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
|
||||
>
|
||||
Where to go when someone clicks on the brand logo in the navbar.
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
bind:value={siteInformation.home}
|
||||
class="mt-2"
|
||||
type="text"
|
||||
id="home"
|
||||
placeholder="/ or https://example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
bind:value={siteInformation.home}
|
||||
class="mt-2"
|
||||
type="text"
|
||||
id="home"
|
||||
placeholder="/ or https://example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col justify-evenly gap-2">
|
||||
<div>
|
||||
<Label for="logo">Logo</Label>
|
||||
</div>
|
||||
<div class="flex gap-x-2">
|
||||
{#if !!siteInformation.logo}
|
||||
<div class="relative">
|
||||
<Label
|
||||
for="logo"
|
||||
class="inline-block h-[60px] w-[60px] cursor-pointer rounded-sm border p-1"
|
||||
>
|
||||
<img
|
||||
src={base + siteInformation.logo}
|
||||
class="w-fit hover:scale-95"
|
||||
alt=""
|
||||
/>
|
||||
</Label>
|
||||
<div class="flex w-full flex-col justify-evenly gap-2">
|
||||
<div>
|
||||
<Label for="logo">Logo</Label>
|
||||
</div>
|
||||
<div class="flex gap-x-4">
|
||||
{#if !!siteInformation.logo}
|
||||
<div class="relative mt-2.5">
|
||||
<Label for="logo" class="inline-block h-[60px] w-[60px] cursor-pointer rounded-sm border p-1">
|
||||
<GMI src={siteInformation.logo} classList="w-fit hover:scale-95" alt="" />
|
||||
</Label>
|
||||
|
||||
<div class="absolute -right-2 -top-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="h-5 w-5 rounded-full border p-1"
|
||||
size="icon"
|
||||
on:click={() => (siteInformation.logo = "")}
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{#if uploadingLogo}
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 transform rounded-sm bg-[rgba(0,0,0,0.5)] p-0.5"
|
||||
>
|
||||
<Loader class="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<Input
|
||||
class=""
|
||||
id="logo"
|
||||
type="file"
|
||||
accept=".jpg, .jpeg, .png"
|
||||
disabled={uploadingLogo}
|
||||
on:change={handleFileChangeLogo}
|
||||
/>
|
||||
<p class="mt-1 text-xs font-medium">
|
||||
Please upload a square image of max size 100KB
|
||||
</p>
|
||||
<p class="mt-1 text-xs font-medium text-destructive">
|
||||
{uploadLogoStatus}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label for="favicon">Favicon</Label>
|
||||
</div>
|
||||
<div class="flex gap-x-2">
|
||||
{#if !!siteInformation.favicon}
|
||||
<div class="relative">
|
||||
<Label
|
||||
for="favicon"
|
||||
class="inline-block h-[40px] w-[40px] cursor-pointer rounded-sm border p-1"
|
||||
>
|
||||
<img
|
||||
src={base + siteInformation.favicon}
|
||||
class="w-fit hover:scale-95"
|
||||
alt=""
|
||||
/>
|
||||
</Label>
|
||||
<div class="absolute -right-2 -top-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="h-5 w-5 rounded-full border p-1"
|
||||
size="icon"
|
||||
on:click={() => (siteInformation.logo = "")}
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{#if uploadingLogo}
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 transform rounded-sm bg-[rgba(0,0,0,0.5)] p-0.5"
|
||||
>
|
||||
<Loader class="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-x-2">
|
||||
<div>
|
||||
<Label for="typeOfLogoUpload" class="text-sm font-medium">Method</Label>
|
||||
<Select.Root
|
||||
portal={null}
|
||||
onSelectedChange={(e) => {
|
||||
typeOfLogoUpload = e.value;
|
||||
}}
|
||||
selected={{
|
||||
value: typeOfLogoUpload,
|
||||
label: typeOfLogoUpload
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="mt-2 w-[200px]" id="typeOfLogoUpload">
|
||||
<Select.Value bind:value={typeOfLogoUpload} placeholder="Logo Type" />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.Label>Logo Type</Select.Label>
|
||||
<Select.Item value="URL" label="URL" class="text-sm font-medium">URL</Select.Item>
|
||||
<Select.Item value="FILE" label="FILE" class="text-sm font-medium">FILE</Select.Item>
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{#if typeOfLogoUpload === "URL"}
|
||||
<div>
|
||||
<Label for="logo" class="text-sm font-medium">Please provide a URL of the image</Label>
|
||||
<Input
|
||||
bind:value={siteInformation.logo}
|
||||
class="mt-2"
|
||||
type="text"
|
||||
id="logo"
|
||||
placeholder="https://example.com/logo.png"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<Label for="logo" class="text-sm font-medium">Please upload a square image of max size 100KB</Label>
|
||||
<Input
|
||||
class="mt-2"
|
||||
id="logo"
|
||||
type="file"
|
||||
accept=".jpg, .jpeg, .png, .svg"
|
||||
disabled={uploadingLogo}
|
||||
on:change={handleFileChangeLogo}
|
||||
/>
|
||||
|
||||
<div class="absolute -right-2 -top-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="h-5 w-5 rounded-full border p-1"
|
||||
size="icon"
|
||||
on:click={() => (siteInformation.favicon = "")}
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{#if uploadingFavicon}
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 transform rounded-sm bg-[rgba(0,0,0,0.5)] p-0.5"
|
||||
>
|
||||
<Loader class="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<Input
|
||||
class=""
|
||||
id="logo"
|
||||
type="file"
|
||||
accept=".jpg, .jpeg, .png, .ico"
|
||||
disabled={uploadingFavicon}
|
||||
on:change={handleFaviconChangeLogo}
|
||||
/>
|
||||
<p class="mt-1 text-xs font-medium">
|
||||
Please upload a square image of max size 20KB
|
||||
</p>
|
||||
<p class="mt-1 text-xs font-medium text-destructive">
|
||||
{uploadFaviconStatus}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full justify-end">
|
||||
<p class="px-4 pt-3 text-sm">
|
||||
<span class="text-red-500">
|
||||
{formErrorMessage}
|
||||
</span>
|
||||
</p>
|
||||
<Button type="submit" disabled={formState === "loading"}>
|
||||
Save
|
||||
{#if formState === "loading"}
|
||||
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card.Content>
|
||||
<p class="mt-1 text-xs font-medium text-destructive">
|
||||
{uploadLogoStatus}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label for="favicon">Favicon</Label>
|
||||
</div>
|
||||
<div class="flex gap-x-4">
|
||||
{#if !!siteInformation.favicon}
|
||||
<div class="relative mt-8">
|
||||
<Label for="favicon" class="inline-block h-[40px] w-[40px] cursor-pointer rounded-sm border p-1">
|
||||
<GMI src={siteInformation.favicon} classList="w-fit hover:scale-95" alt="" />
|
||||
</Label>
|
||||
|
||||
<div class="absolute -right-2 -top-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="h-5 w-5 rounded-full border p-1"
|
||||
size="icon"
|
||||
on:click={() => (siteInformation.favicon = "")}
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{#if uploadingFavicon}
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 transform rounded-sm bg-[rgba(0,0,0,0.5)] p-0.5"
|
||||
>
|
||||
<Loader class="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-x-2">
|
||||
<div>
|
||||
<Label for="typeOfFaviconUpload" class="text-sm font-medium">Method</Label>
|
||||
<Select.Root
|
||||
portal={null}
|
||||
onSelectedChange={(e) => {
|
||||
typeOfFaviconUpload = e.value;
|
||||
}}
|
||||
selected={{
|
||||
value: typeOfFaviconUpload,
|
||||
label: typeOfFaviconUpload
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="mt-2 w-[200px]" id="typeOfFaviconUpload">
|
||||
<Select.Value bind:value={typeOfFaviconUpload} placeholder="Favicon Type" />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.Label>Favicon Type</Select.Label>
|
||||
<Select.Item value="URL" label="URL" class="text-sm font-medium">URL</Select.Item>
|
||||
<Select.Item value="FILE" label="FILE" class="text-sm font-medium">FILE</Select.Item>
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{#if typeOfFaviconUpload === "URL"}
|
||||
<div>
|
||||
<Label for="favicon" class="text-sm font-medium">Please provide a URL of the image</Label>
|
||||
<Input
|
||||
bind:value={siteInformation.favicon}
|
||||
class="mt-2"
|
||||
type="text"
|
||||
id="favicon"
|
||||
placeholder="https://example.com/favicon.png"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<Label for="favicon" class="text-sm font-medium">Please upload a square image of max size 20KB</Label>
|
||||
<Input
|
||||
class="mt-2"
|
||||
id="favicon"
|
||||
type="file"
|
||||
accept=".jpg, .jpeg, .png, .svg"
|
||||
disabled={uploadingFavicon}
|
||||
on:change={handleFaviconChangeLogo}
|
||||
/>
|
||||
|
||||
<p class="mt-1 text-xs font-medium text-destructive">
|
||||
{uploadFaviconStatus}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full justify-end">
|
||||
<p class="px-4 pt-3 text-sm">
|
||||
<span class="text-red-500">
|
||||
{formErrorMessage}
|
||||
</span>
|
||||
</p>
|
||||
<Button type="submit" disabled={formState === "loading"}>
|
||||
Save
|
||||
{#if formState === "loading"}
|
||||
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- GitHub Settings -->
|
||||
|
||||
@@ -1,381 +1,358 @@
|
||||
<script>
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Plus, X, Info } from "lucide-svelte";
|
||||
import autoAnimate from "@formkit/auto-animate";
|
||||
import { siteDataExtractFromDb, storeSiteData } from "$lib/clientTools.js";
|
||||
import { base } from "$app/paths";
|
||||
import { Loader } from "lucide-svelte";
|
||||
import * as RadioGroup from "$lib/components/ui/radio-group";
|
||||
import { Tooltip } from "bits-ui";
|
||||
import ColorPicker from "svelte-awesome-color-picker";
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Plus, X, Info } from "lucide-svelte";
|
||||
import autoAnimate from "@formkit/auto-animate";
|
||||
import { siteDataExtractFromDb, storeSiteData } from "$lib/clientTools.js";
|
||||
import { base } from "$app/paths";
|
||||
import { Loader } from "lucide-svelte";
|
||||
import * as RadioGroup from "$lib/components/ui/radio-group";
|
||||
import { Tooltip } from "bits-ui";
|
||||
import ColorPicker from "svelte-awesome-color-picker";
|
||||
|
||||
export let data;
|
||||
export let data;
|
||||
|
||||
let formState = "idle";
|
||||
let formState = "idle";
|
||||
|
||||
let themeData = {
|
||||
pattern: "none",
|
||||
theme: "light",
|
||||
themeToggle: "YES",
|
||||
barStyle: "PARTIAL",
|
||||
barRoundness: "ROUNDED",
|
||||
summaryStyle: "CURRENT",
|
||||
colorsJ: {
|
||||
UP: "#4ead94",
|
||||
DOWN: "#ca3038",
|
||||
DEGRADED: "#e6ca61"
|
||||
},
|
||||
fontJ: {
|
||||
cssSrc: "https://fonts.bunny.net/css?family=albert-sans:100,100i,200,200i,300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i&display=swap",
|
||||
family: "Albert Sans"
|
||||
},
|
||||
colors: "",
|
||||
font: "",
|
||||
customCSS: ""
|
||||
};
|
||||
let themeData = {
|
||||
pattern: "none",
|
||||
theme: "light",
|
||||
themeToggle: "YES",
|
||||
barStyle: "PARTIAL",
|
||||
barRoundness: "ROUNDED",
|
||||
summaryStyle: "CURRENT",
|
||||
colorsJ: {
|
||||
UP: "#4ead94",
|
||||
DOWN: "#ca3038",
|
||||
DEGRADED: "#e6ca61"
|
||||
},
|
||||
fontJ: {
|
||||
cssSrc:
|
||||
"https://fonts.bunny.net/css?family=albert-sans:100,100i,200,200i,300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i&display=swap",
|
||||
family: "Albert Sans"
|
||||
},
|
||||
colors: "",
|
||||
font: "",
|
||||
customCSS: ""
|
||||
};
|
||||
|
||||
themeData = siteDataExtractFromDb(data.siteData, themeData);
|
||||
themeData = siteDataExtractFromDb(data.siteData, themeData);
|
||||
|
||||
if (data.siteData.colors) {
|
||||
themeData.colorsJ = data.siteData.colors;
|
||||
}
|
||||
if (data.siteData.font) {
|
||||
themeData.fontJ = data.siteData.font;
|
||||
}
|
||||
if (data.siteData.colors) {
|
||||
themeData.colorsJ = data.siteData.colors;
|
||||
}
|
||||
if (data.siteData.font) {
|
||||
themeData.fontJ = data.siteData.font;
|
||||
}
|
||||
|
||||
async function formSubmit() {
|
||||
formState = "loading";
|
||||
let themeDataAPI = { ...themeData };
|
||||
themeDataAPI.colors = JSON.stringify(themeDataAPI.colorsJ);
|
||||
themeDataAPI.font = JSON.stringify(themeDataAPI.fontJ);
|
||||
delete themeDataAPI.colorsJ;
|
||||
delete themeDataAPI.fontJ;
|
||||
let resp = await storeSiteData(themeDataAPI);
|
||||
//print data
|
||||
let data = await resp.json();
|
||||
formState = "idle";
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
async function formSubmit() {
|
||||
formState = "loading";
|
||||
let themeDataAPI = { ...themeData };
|
||||
themeDataAPI.colors = JSON.stringify(themeDataAPI.colorsJ);
|
||||
themeDataAPI.font = JSON.stringify(themeDataAPI.fontJ);
|
||||
delete themeDataAPI.colorsJ;
|
||||
delete themeDataAPI.fontJ;
|
||||
let resp = await storeSiteData(themeDataAPI);
|
||||
//print data
|
||||
let data = await resp.json();
|
||||
formState = "idle";
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root class="mt-4">
|
||||
<Card.Header class="border-b">
|
||||
<Card.Title>Theme Customization</Card.Title>
|
||||
<Card.Description>
|
||||
You can customize the theme of your site here. You can change the pattern, theme,
|
||||
colors, font, and monitor style.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form class="mx-auto mt-4 space-y-4" on:submit|preventDefault={formSubmit}>
|
||||
<p class="font-medium">
|
||||
Home Page Pattern
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger class="">
|
||||
<Info class="inline-block h-4 w-4 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="max-w-md">
|
||||
<div
|
||||
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
|
||||
>
|
||||
Choose the pattern for the home page. You can choose between squares,
|
||||
dots, or none.
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</p>
|
||||
<Card.Header class="border-b">
|
||||
<Card.Title>Theme Customization</Card.Title>
|
||||
<Card.Description>
|
||||
You can customize the theme of your site here. You can change the pattern, theme, colors, font, and monitor style.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form class="mx-auto mt-4 space-y-4" on:submit|preventDefault={formSubmit}>
|
||||
<p class="font-medium">
|
||||
Home Page Pattern
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger class="">
|
||||
<Info class="inline-block h-4 w-4 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="max-w-md">
|
||||
<div
|
||||
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
|
||||
>
|
||||
Choose the pattern for the home page. You can choose between squares, dots, or none.
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</p>
|
||||
|
||||
<div class="flex">
|
||||
<RadioGroup.Root
|
||||
bind:value={themeData.pattern}
|
||||
onValueChange={(e) => (themeData.pattern = e)}
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="squares" id="rsq" />
|
||||
<Label for="rsq">Squares</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="dots" id="rdot" />
|
||||
<Label for="rdot">Dots</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="none" id="rnone" />
|
||||
<Label for="rnone">None</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
<hr />
|
||||
<p class="font-medium">
|
||||
Default Theme
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger class="">
|
||||
<Info class="inline-block h-4 w-4 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="max-w-md">
|
||||
<div
|
||||
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
|
||||
>
|
||||
Choose the default theme for the site. You can choose between light,
|
||||
dark, or system.
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</p>
|
||||
<div class="flex">
|
||||
<RadioGroup.Root
|
||||
bind:value={themeData.theme}
|
||||
onValueChange={(e) => (themeData.theme = e)}
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="none" id="none_theme" />
|
||||
<Label for="none_theme">None</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="light" id="light_theme" />
|
||||
<Label for="light_theme">Light</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="dark" id="dark_theme" />
|
||||
<Label for="dark_theme">Dark</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="system" id="system_theme" />
|
||||
<Label for="system_theme">System</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
<p class="font-medium">
|
||||
<label>
|
||||
<input
|
||||
on:change={(e) => {
|
||||
themeData.themeToggle = e.target.checked === true ? "YES" : "NO";
|
||||
}}
|
||||
type="checkbox"
|
||||
checked={themeData.themeToggle === "YES"}
|
||||
/>
|
||||
Let Users toggle theme between light and dark
|
||||
</label>
|
||||
</p>
|
||||
<hr />
|
||||
<p class="font-medium">Monitor Style</p>
|
||||
<div class="flex">
|
||||
<RadioGroup.Root bind:value={themeData.pattern} onValueChange={(e) => (themeData.pattern = e)}>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="squares" id="rsq" />
|
||||
<Label for="rsq">Squares</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="dots" id="rdot" />
|
||||
<Label for="rdot">Dots</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="none" id="rnone" />
|
||||
<Label for="rnone">None</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
<hr />
|
||||
<p class="font-medium">
|
||||
Default Theme
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger class="">
|
||||
<Info class="inline-block h-4 w-4 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="max-w-md">
|
||||
<div
|
||||
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
|
||||
>
|
||||
Choose the default theme for the site. You can choose between light, dark, or system.
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</p>
|
||||
<div class="flex">
|
||||
<RadioGroup.Root bind:value={themeData.theme} onValueChange={(e) => (themeData.theme = e)}>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="none" id="none_theme" />
|
||||
<Label for="none_theme">None</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="light" id="light_theme" />
|
||||
<Label for="light_theme">Light</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="dark" id="dark_theme" />
|
||||
<Label for="dark_theme">Dark</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="system" id="system_theme" />
|
||||
<Label for="system_theme">System</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
<p class="font-medium">
|
||||
<label>
|
||||
<input
|
||||
on:change={(e) => {
|
||||
themeData.themeToggle = e.target.checked === true ? "YES" : "NO";
|
||||
}}
|
||||
type="checkbox"
|
||||
checked={themeData.themeToggle === "YES"}
|
||||
/>
|
||||
Let Users toggle theme between light and dark
|
||||
</label>
|
||||
</p>
|
||||
<hr />
|
||||
<p class="font-medium">Monitor Style</p>
|
||||
|
||||
<div class="flex gap-x-24">
|
||||
<RadioGroup.Root
|
||||
bind:value={themeData.barStyle}
|
||||
onValueChange={(e) => (themeData.barStyle = e)}
|
||||
>
|
||||
<p class="text-sm font-medium text-muted-foreground">
|
||||
Status bar
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger class="">
|
||||
<Info class="inline-block h-4 w-4 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="max-w-md">
|
||||
<div
|
||||
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
|
||||
>
|
||||
Choose the style for the monitor. You can choose between partial
|
||||
or full.
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</p>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="PARTIAL" id="barStylePARTIAL" />
|
||||
<Label for="barStylePARTIAL">PARTIAL</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="FULL" id="barStyleFULL" />
|
||||
<Label for="barStyleFULL">FULL</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
<RadioGroup.Root
|
||||
bind:value={themeData.barRoundness}
|
||||
onValueChange={(e) => (themeData.barRoundness = e)}
|
||||
>
|
||||
<p class="text-sm font-medium text-muted-foreground">
|
||||
Roundness
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger class="">
|
||||
<Info class="inline-block h-4 w-4 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="max-w-md">
|
||||
<div
|
||||
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
|
||||
>
|
||||
Choose the roundness for the monitor. You can choose between
|
||||
rounded or sharp.
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</p>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="SHARP" id="barRoundnessSHARP" />
|
||||
<Label for="barRoundnessSHARP">SHARP</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="ROUNDED" id="barRoundnessRound" />
|
||||
<Label for="barRoundnessRound">ROUNDED</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
<RadioGroup.Root
|
||||
bind:value={themeData.summaryStyle}
|
||||
onValueChange={(e) => (themeData.summaryStyle = e)}
|
||||
>
|
||||
<p class="text-sm font-medium text-muted-foreground">
|
||||
Summary Type
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger class="">
|
||||
<Info class="inline-block h-4 w-4 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="max-w-md">
|
||||
<div
|
||||
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
|
||||
>
|
||||
Choose the summary type for the monitor. You can choose between
|
||||
current or day.
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</p>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="CURRENT" id="summaryStyle1" />
|
||||
<Label for="summaryStyle1">CURRENT</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="DAY" id="summaryStyle2" />
|
||||
<Label for="summaryStyle2">DAY</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
<hr />
|
||||
<p class="font-medium">
|
||||
Colors
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger class="">
|
||||
<Info class="inline-block h-4 w-4 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="max-w-md">
|
||||
<div
|
||||
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
|
||||
>
|
||||
Choose the colors for the monitor. You can choose between UP, DEGRADED,
|
||||
and DOWN.
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</p>
|
||||
<div class="flex w-full flex-row justify-evenly gap-2">
|
||||
<div class="relative w-full">
|
||||
<ColorPicker
|
||||
bind:hex={themeData.colorsJ.UP}
|
||||
position="responsive"
|
||||
label="UP"
|
||||
--cp-input-color="#777"
|
||||
--cp-button-hover-color="#767676"
|
||||
on:input={(event) => {
|
||||
themeData.colorsJ.UP = event.detail.hex;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="relative w-full">
|
||||
<ColorPicker
|
||||
bind:hex={themeData.colorsJ.DEGRADED}
|
||||
position="responsive"
|
||||
label="DEGRADED"
|
||||
--cp-input-color="#777"
|
||||
--cp-button-hover-color="#767676"
|
||||
on:input={(event) => {
|
||||
themeData.colorsJ.DEGRADED = event.detail.hex;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="relative w-full">
|
||||
<ColorPicker
|
||||
bind:hex={themeData.colorsJ.DOWN}
|
||||
position="responsive"
|
||||
label="DOWN"
|
||||
--cp-input-color="#777"
|
||||
--cp-button-hover-color="#767676"
|
||||
on:input={(event) => {
|
||||
themeData.colorsJ.DOWN = event.detail.hex;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<p class="font-medium">
|
||||
Font
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger class="">
|
||||
<Info class="inline-block h-4 w-4 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="max-w-md">
|
||||
<div
|
||||
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
|
||||
>
|
||||
Choose the font for the monitor. You can choose the font URL and font
|
||||
family name.
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</p>
|
||||
<div class="flex w-full flex-row justify-evenly gap-2">
|
||||
<div class="w-full">
|
||||
<Label for="cssSrc" class="text-sm text-muted-foreground">Font URL</Label>
|
||||
<Input
|
||||
bind:value={themeData.fontJ.cssSrc}
|
||||
class="mt-2"
|
||||
type="text"
|
||||
id="cssSrc"
|
||||
placeholder="https://fonts.bunny.net/css?family=lato:100,100i,300,300i,400,400i,700,700i,900,900i&display=swap"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-row justify-start gap-2">
|
||||
<div class="w-48">
|
||||
<Label for="fontName" class="text-sm text-muted-foreground"
|
||||
>Font Family Name</Label
|
||||
>
|
||||
<Input
|
||||
bind:value={themeData.fontJ.family}
|
||||
class="mt-2"
|
||||
type="text"
|
||||
id="fontName"
|
||||
placeholder="Lato"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<p class="font-medium">Custom CSS</p>
|
||||
<div class="flex w-full flex-row justify-start gap-2">
|
||||
<div class="w-full">
|
||||
<Label for="customCSS" class="text-sm text-muted-foreground">
|
||||
You can add custom CSS to style your site. Do not include style tags.
|
||||
</Label>
|
||||
<textarea
|
||||
bind:value={themeData.customCSS}
|
||||
id="customCSS"
|
||||
class="mt-1 h-48 w-full rounded-sm border p-2"
|
||||
placeholder=".className{color: red;}"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="flex w-full justify-end">
|
||||
<Button type="submit" disabled={formState === "loading"}>
|
||||
Save
|
||||
{#if formState === "loading"}
|
||||
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card.Content>
|
||||
<div class="flex gap-x-24">
|
||||
<RadioGroup.Root bind:value={themeData.barStyle} onValueChange={(e) => (themeData.barStyle = e)}>
|
||||
<p class="text-sm font-medium text-muted-foreground">
|
||||
Status bar
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger class="">
|
||||
<Info class="inline-block h-4 w-4 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="max-w-md">
|
||||
<div
|
||||
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
|
||||
>
|
||||
Choose the style for the monitor. You can choose between partial or full.
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</p>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="PARTIAL" id="barStylePARTIAL" />
|
||||
<Label for="barStylePARTIAL">PARTIAL</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="FULL" id="barStyleFULL" />
|
||||
<Label for="barStyleFULL">FULL</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
<RadioGroup.Root bind:value={themeData.barRoundness} onValueChange={(e) => (themeData.barRoundness = e)}>
|
||||
<p class="text-sm font-medium text-muted-foreground">
|
||||
Roundness
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger class="">
|
||||
<Info class="inline-block h-4 w-4 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="max-w-md">
|
||||
<div
|
||||
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
|
||||
>
|
||||
Choose the roundness for the monitor. You can choose between rounded or sharp.
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</p>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="SHARP" id="barRoundnessSHARP" />
|
||||
<Label for="barRoundnessSHARP">SHARP</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="ROUNDED" id="barRoundnessRound" />
|
||||
<Label for="barRoundnessRound">ROUNDED</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
<RadioGroup.Root bind:value={themeData.summaryStyle} onValueChange={(e) => (themeData.summaryStyle = e)}>
|
||||
<p class="text-sm font-medium text-muted-foreground">
|
||||
Summary Type
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger class="">
|
||||
<Info class="inline-block h-4 w-4 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="max-w-md">
|
||||
<div
|
||||
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
|
||||
>
|
||||
Choose the summary type for the monitor. You can choose between current or day.
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</p>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="CURRENT" id="summaryStyle1" />
|
||||
<Label for="summaryStyle1">CURRENT</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="DAY" id="summaryStyle2" />
|
||||
<Label for="summaryStyle2">DAY</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
<hr />
|
||||
<p class="font-medium">
|
||||
Colors
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger class="">
|
||||
<Info class="inline-block h-4 w-4 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="max-w-md">
|
||||
<div
|
||||
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
|
||||
>
|
||||
Choose the colors for the monitor. You can choose between UP, DEGRADED, and DOWN.
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</p>
|
||||
<div class="flex w-full flex-row justify-evenly gap-2">
|
||||
<div class="relative w-full">
|
||||
<ColorPicker
|
||||
bind:hex={themeData.colorsJ.UP}
|
||||
position="responsive"
|
||||
label="UP"
|
||||
--cp-input-color="#777"
|
||||
--cp-button-hover-color="#767676"
|
||||
on:input={(event) => {
|
||||
themeData.colorsJ.UP = event.detail.hex;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="relative w-full">
|
||||
<ColorPicker
|
||||
bind:hex={themeData.colorsJ.DEGRADED}
|
||||
position="responsive"
|
||||
label="DEGRADED"
|
||||
--cp-input-color="#777"
|
||||
--cp-button-hover-color="#767676"
|
||||
on:input={(event) => {
|
||||
themeData.colorsJ.DEGRADED = event.detail.hex;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="relative w-full">
|
||||
<ColorPicker
|
||||
bind:hex={themeData.colorsJ.DOWN}
|
||||
position="responsive"
|
||||
label="DOWN"
|
||||
--cp-input-color="#777"
|
||||
--cp-button-hover-color="#767676"
|
||||
on:input={(event) => {
|
||||
themeData.colorsJ.DOWN = event.detail.hex;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<p class="font-medium">
|
||||
Font
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger class="">
|
||||
<Info class="inline-block h-4 w-4 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="max-w-md">
|
||||
<div
|
||||
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
|
||||
>
|
||||
Choose the font for the monitor. You can choose the font URL and font family name.
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</p>
|
||||
<div class="flex w-full flex-row justify-evenly gap-2">
|
||||
<div class="w-full">
|
||||
<Label for="cssSrc" class="text-sm text-muted-foreground">Font URL</Label>
|
||||
<Input
|
||||
bind:value={themeData.fontJ.cssSrc}
|
||||
class="mt-2"
|
||||
type="text"
|
||||
id="cssSrc"
|
||||
placeholder="https://fonts.bunny.net/css?family=lato:100,100i,300,300i,400,400i,700,700i,900,900i&display=swap"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-row justify-start gap-2">
|
||||
<div class="w-48">
|
||||
<Label for="fontName" class="text-sm text-muted-foreground">Font Family Name</Label>
|
||||
<Input bind:value={themeData.fontJ.family} class="mt-2" type="text" id="fontName" placeholder="Lato" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Want to upload and use custom font? Read more about it <a
|
||||
href="https://kener.ing/docs/custom-fonts/"
|
||||
class="text-card-foreground"
|
||||
target="_blank">here</a
|
||||
>.
|
||||
</p>
|
||||
<hr />
|
||||
<p class="font-medium">Custom CSS</p>
|
||||
<div class="flex w-full flex-row justify-start gap-2">
|
||||
<div class="w-full">
|
||||
<Label for="customCSS" class="text-sm text-muted-foreground">
|
||||
You can add custom CSS to style your site. Do not include style tags.
|
||||
</Label>
|
||||
<textarea
|
||||
bind:value={themeData.customCSS}
|
||||
id="customCSS"
|
||||
class="mt-1 h-48 w-full rounded-sm border p-2"
|
||||
placeholder=".className{color: red;}"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="flex w-full justify-end">
|
||||
<Button type="submit" disabled={formState === "loading"}>
|
||||
Save
|
||||
{#if formState === "loading"}
|
||||
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -312,7 +312,11 @@
|
||||
detail: showAddTrigger
|
||||
});
|
||||
window.dispatchEvent(noScrollEvent);
|
||||
if (!showAddTrigger) {
|
||||
window.location.hash = "";
|
||||
}
|
||||
}
|
||||
//if modal closed then clear url hashed
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -325,12 +329,8 @@
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.Label>Status</Select.Label>
|
||||
<Select.Item value="ACTIVE" label="ACTIVE" class="text-sm font-medium">
|
||||
ACTIVE
|
||||
</Select.Item>
|
||||
<Select.Item value="INACTIVE" label="INACTIVE" class="text-sm font-medium">
|
||||
INACTIVE
|
||||
</Select.Item>
|
||||
<Select.Item value="ACTIVE" label="ACTIVE" class="text-sm font-medium">ACTIVE</Select.Item>
|
||||
<Select.Item value="INACTIVE" label="INACTIVE" class="text-sm font-medium">INACTIVE</Select.Item>
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
@@ -350,29 +350,13 @@
|
||||
<Card.Header class="relative">
|
||||
<Card.Title>
|
||||
{#if trigger.trigger_type == "webhook"}
|
||||
<img
|
||||
src={base + "/webhooks.svg"}
|
||||
alt={trigger.trigger_type}
|
||||
class="mr-2 inline-block h-6 w-6"
|
||||
/>
|
||||
<img src={base + "/webhooks.svg"} alt={trigger.trigger_type} class="mr-2 inline-block h-6 w-6" />
|
||||
{:else if trigger.trigger_type == "slack"}
|
||||
<img
|
||||
src={base + "/slack.svg"}
|
||||
alt={trigger.trigger_type}
|
||||
class="mr-2 inline-block h-6 w-6"
|
||||
/>
|
||||
<img src={base + "/slack.svg"} alt={trigger.trigger_type} class="mr-2 inline-block h-6 w-6" />
|
||||
{:else if trigger.trigger_type == "discord"}
|
||||
<img
|
||||
src={base + "/discord.svg"}
|
||||
alt={trigger.trigger_type}
|
||||
class="mr-2 inline-block h-6 w-6"
|
||||
/>
|
||||
<img src={base + "/discord.svg"} alt={trigger.trigger_type} class="mr-2 inline-block h-6 w-6" />
|
||||
{:else if trigger.trigger_type == "email"}
|
||||
<img
|
||||
src={base + "/email.png"}
|
||||
alt={trigger.trigger_type}
|
||||
class="mr-2 inline-block h-6 w-6"
|
||||
/>
|
||||
<img src={base + "/email.png"} alt={trigger.trigger_type} class="mr-2 inline-block h-6 w-6" />
|
||||
{/if}
|
||||
{trigger.name}
|
||||
</Card.Title>
|
||||
@@ -435,8 +419,7 @@
|
||||
class="peer sr-only"
|
||||
checked={newTrigger.trigger_status == "ACTIVE"}
|
||||
on:change={() => {
|
||||
newTrigger.trigger_status =
|
||||
newTrigger.trigger_status == "ACTIVE" ? "INACTIVE" : "ACTIVE";
|
||||
newTrigger.trigger_status = newTrigger.trigger_status == "ACTIVE" ? "INACTIVE" : "ACTIVE";
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
@@ -447,8 +430,7 @@
|
||||
</div>
|
||||
<div class="mt-12 w-full overflow-y-auto p-3" style="height: calc(100vh - 7rem);">
|
||||
<p class="mb-4">
|
||||
Select the type of Trigger you want to add. You can add multiple Triggers of different
|
||||
types.
|
||||
Select the type of Trigger you want to add. You can add multiple Triggers of different types.
|
||||
</p>
|
||||
<div class="flex justify-stretch gap-4">
|
||||
<Button
|
||||
@@ -513,11 +495,7 @@
|
||||
</span>
|
||||
Trigger
|
||||
</Label>
|
||||
<Input
|
||||
class="mt-2"
|
||||
bind:value={newTrigger.trigger_desc}
|
||||
placeholder="Example: This is a trigger."
|
||||
/>
|
||||
<Input class="mt-2" bind:value={newTrigger.trigger_desc} placeholder="Example: This is a trigger." />
|
||||
</div>
|
||||
{#if newTrigger.trigger_type != "email"}
|
||||
<div class="mt-4 w-full">
|
||||
@@ -526,11 +504,7 @@
|
||||
Trigger
|
||||
<span class="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
class="mt-2"
|
||||
bind:value={newTrigger.trigger_meta.url}
|
||||
placeholder="https://example.com/url"
|
||||
/>
|
||||
<Input class="mt-2" bind:value={newTrigger.trigger_meta.url} placeholder="https://example.com/url" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if newTrigger.trigger_type == "webhook"}
|
||||
@@ -549,9 +523,7 @@
|
||||
class=" h-6 w-6 p-1"
|
||||
variant="secondary"
|
||||
on:click={() => {
|
||||
newTrigger.trigger_meta.headers = newTrigger.trigger_meta.headers.filter(
|
||||
(_, i) => i !== index
|
||||
);
|
||||
newTrigger.trigger_meta.headers = newTrigger.trigger_meta.headers.filter((_, i) => i !== index);
|
||||
}}
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
@@ -583,8 +555,8 @@
|
||||
</label>
|
||||
{#if !!newTrigger.trigger_meta.has_webhook_body}
|
||||
<p class="my-2 text-xs text-muted-foreground">
|
||||
You can use a custom webhook body. The body should be a valid for the
|
||||
Content-Type header you have set. The default is JSON. There are <a
|
||||
You can use a custom webhook body. The body should be a valid for the Content-Type header you have
|
||||
set. The default is JSON. There are <a
|
||||
target="_blank"
|
||||
class="text-blue-500"
|
||||
href="https://kener.ing/docs/triggers#webhook-body">variables</a
|
||||
@@ -618,15 +590,12 @@
|
||||
<div class="rounded-md border bg-card p-2 text-xs">
|
||||
<p class="text-sm font-semibold">Email Trigger</p>
|
||||
<p class="text-xs">
|
||||
Kener uses <a href="https://resend.com/" class="text-blue-500" target="_blank"
|
||||
>resend</a
|
||||
>
|
||||
to send emails. Please make sure you have created an account with resend. Also add
|
||||
the resend api key as environment variable
|
||||
Kener uses <a href="https://resend.com/" class="text-blue-500" target="_blank">resend</a>
|
||||
to send emails. Please make sure you have created an account with resend. Also add the resend api key
|
||||
as environment variable
|
||||
<span class="font-mono">RESEND_API_KEY</span>.
|
||||
<span class="text-red-500"
|
||||
>The RESEND_API_KEY is not set in your environment variable. Please set it and
|
||||
restart the server</span
|
||||
>The RESEND_API_KEY is not set in your environment variable. Please set it and restart the server</span
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
@@ -637,44 +606,28 @@
|
||||
Host
|
||||
<span class="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
class="mt-2"
|
||||
bind:value={newTrigger.trigger_meta.smtp_host}
|
||||
placeholder="smtp.example.com"
|
||||
/>
|
||||
<Input class="mt-2" bind:value={newTrigger.trigger_meta.smtp_host} placeholder="smtp.example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-sm">
|
||||
Port
|
||||
<span class="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
class="mt-2"
|
||||
bind:value={newTrigger.trigger_meta.smtp_port}
|
||||
placeholder="587"
|
||||
/>
|
||||
<Input class="mt-2" bind:value={newTrigger.trigger_meta.smtp_port} placeholder="587" />
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-sm">
|
||||
User
|
||||
<span class="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
class="mt-2"
|
||||
bind:value={newTrigger.trigger_meta.smtp_user}
|
||||
placeholder="raj@example.com"
|
||||
/>
|
||||
<Input class="mt-2" bind:value={newTrigger.trigger_meta.smtp_user} placeholder="raj@example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-sm">
|
||||
Password
|
||||
<span class="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
class="mt-2"
|
||||
bind:value={newTrigger.trigger_meta.smtp_pass}
|
||||
placeholder="*******"
|
||||
/>
|
||||
<Input class="mt-2" bind:value={newTrigger.trigger_meta.smtp_pass} placeholder="*******" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-2">
|
||||
@@ -707,27 +660,17 @@
|
||||
Add Name and Sender Email Address
|
||||
<span class="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
class="mt-2"
|
||||
bind:value={newTrigger.trigger_meta.from}
|
||||
placeholder="Alerts <alert@example.com>"
|
||||
/>
|
||||
<Input class="mt-2" bind:value={newTrigger.trigger_meta.from} placeholder="Alerts <alert@example.com>" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-0 grid h-16 w-full grid-cols-6 justify-end gap-2 border-t p-3 pr-6"
|
||||
>
|
||||
<div class="absolute bottom-0 grid h-16 w-full grid-cols-6 justify-end gap-2 border-t p-3 pr-6">
|
||||
<div class="col-span-5 py-2.5">
|
||||
<p class="text-right text-xs font-medium text-red-500">{invalidFormMessage}</p>
|
||||
</div>
|
||||
<div class="col-span-1">
|
||||
<Button
|
||||
class="col-span-1 w-full"
|
||||
disabled={formState === "loading"}
|
||||
on:click={addNewTrigger}
|
||||
>
|
||||
<Button class="col-span-1 w-full" disabled={formState === "loading"} on:click={addNewTrigger}>
|
||||
Save
|
||||
{#if formState === "loading"}
|
||||
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -1,408 +1,388 @@
|
||||
<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 { base } from "$app/paths";
|
||||
import { sub, startOfDay, getUnixTime } from "date-fns";
|
||||
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 { base } from "$app/paths";
|
||||
import { sub, startOfDay, getUnixTime } from "date-fns";
|
||||
import GMI from "$lib/components/gmi.svelte";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
import {
|
||||
Share2,
|
||||
Link,
|
||||
CopyCheck,
|
||||
Code,
|
||||
TrendingUp,
|
||||
Percent,
|
||||
Loader,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from "lucide-svelte";
|
||||
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 "svelte-legos";
|
||||
import LoaderBoxes from "$lib/components/loaderbox.svelte";
|
||||
import NumberFlow from "@number-flow/svelte";
|
||||
import Incident from "$lib/components/IncidentNew.svelte";
|
||||
import { Share2, ArrowRight, Settings, TrendingUp, Loader, ChevronLeft, ChevronRight } from "lucide-svelte";
|
||||
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 "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();
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let monitor;
|
||||
export let monitor;
|
||||
export let localTz;
|
||||
export let lang;
|
||||
export let embed = false;
|
||||
export let selectedLang = "en";
|
||||
|
||||
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 _0Day = {};
|
||||
let _90Day = monitor.pageData._90Day;
|
||||
let uptime90Day = monitor.pageData.uptime90Day;
|
||||
let incidents = {};
|
||||
let dayIncidentsFull = [];
|
||||
function loadIncidents() {
|
||||
axios
|
||||
.post(`${base}/api/today/incidents`, {
|
||||
tag: monitor.tag,
|
||||
startTs: monitor.pageData.midnight90DaysAgo,
|
||||
endTs: monitor.pageData.midnightTomorrow,
|
||||
localTz: localTz
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data) {
|
||||
incidents = response.data;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
}
|
||||
|
||||
function loadIncidents() {
|
||||
axios
|
||||
.post(`${base}/api/today/incidents`, {
|
||||
tag: monitor.tag,
|
||||
startTs: monitor.pageData.midnight90DaysAgo,
|
||||
endTs: monitor.pageData.midnightTomorrow,
|
||||
localTz: localTz
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data) {
|
||||
incidents = response.data;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
}
|
||||
function getToday(startTs, incidentIDs) {
|
||||
axios
|
||||
.post(`${base}/api/today`, {
|
||||
monitor: monitor,
|
||||
localTz: localTz,
|
||||
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 getToday(startTs, incidentIDs) {
|
||||
axios
|
||||
.post(`${base}/api/today`, {
|
||||
monitor: monitor,
|
||||
localTz: localTz,
|
||||
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);
|
||||
}
|
||||
let uptimesRollers = [
|
||||
{
|
||||
text: `${l(lang, "90 Days")}`,
|
||||
startTs: getUnixTime(startOfDay(sub(new Date(), { days: 90 }))),
|
||||
value: uptime90Day
|
||||
},
|
||||
{
|
||||
text: `${l(lang, "60 Days")}`,
|
||||
startTs: getUnixTime(startOfDay(sub(new Date(), { days: 59 })))
|
||||
},
|
||||
{
|
||||
text: `${l(lang, "30 Days")}`,
|
||||
startTs: getUnixTime(startOfDay(sub(new Date(), { days: 29 })))
|
||||
},
|
||||
{
|
||||
text: `${l(lang, "14 Days")}`,
|
||||
startTs: getUnixTime(startOfDay(sub(new Date(), { days: 13 })))
|
||||
},
|
||||
{
|
||||
text: `${l(lang, "7 Days")}`,
|
||||
startTs: getUnixTime(startOfDay(sub(new Date(), { days: 6 })))
|
||||
},
|
||||
{
|
||||
text: l(lang, "Today"),
|
||||
startTs: getUnixTime(startOfDay(new Date()))
|
||||
}
|
||||
];
|
||||
|
||||
function scrollToRight() {
|
||||
setTimeout(() => {
|
||||
let divs = document.querySelectorAll(".daygrid90");
|
||||
divs.forEach((div) => {
|
||||
div.scrollLeft = div.scrollWidth;
|
||||
});
|
||||
}, 1000 * 0.2);
|
||||
}
|
||||
let uptimesRollers = [
|
||||
{
|
||||
text: `${l(lang, "90 Days")}`,
|
||||
startTs: getUnixTime(startOfDay(sub(new Date(), { days: 90 }))),
|
||||
value: uptime90Day
|
||||
},
|
||||
{
|
||||
text: `${l(lang, "60 Days")}`,
|
||||
startTs: getUnixTime(startOfDay(sub(new Date(), { days: 59 })))
|
||||
},
|
||||
{
|
||||
text: `${l(lang, "30 Days")}`,
|
||||
startTs: getUnixTime(startOfDay(sub(new Date(), { days: 29 })))
|
||||
},
|
||||
{
|
||||
text: `${l(lang, "14 Days")}`,
|
||||
startTs: getUnixTime(startOfDay(sub(new Date(), { days: 13 })))
|
||||
},
|
||||
{
|
||||
text: `${l(lang, "7 Days")}`,
|
||||
startTs: getUnixTime(startOfDay(sub(new Date(), { days: 6 })))
|
||||
},
|
||||
{
|
||||
text: l(lang, "Today"),
|
||||
startTs: getUnixTime(startOfDay(new Date()))
|
||||
}
|
||||
];
|
||||
//start of the week moment
|
||||
let rolledAt = 0;
|
||||
let rollerLoading = false;
|
||||
async function rollSummary(r) {
|
||||
let newRolledAt = (rolledAt + r) % uptimesRollers.length;
|
||||
|
||||
//start of the week moment
|
||||
let rolledAt = 0;
|
||||
let rollerLoading = false;
|
||||
async function rollSummary(r) {
|
||||
let newRolledAt = (rolledAt + r) % uptimesRollers.length;
|
||||
if (uptimesRollers[newRolledAt].value === undefined) {
|
||||
rollerLoading = true;
|
||||
uptimesRollers[newRolledAt].loading = true;
|
||||
|
||||
if (uptimesRollers[newRolledAt].value === undefined) {
|
||||
rollerLoading = true;
|
||||
uptimesRollers[newRolledAt].loading = true;
|
||||
let resp = await axios.post(`${base}/api/today/aggregated`, {
|
||||
monitor: monitor,
|
||||
startTs: uptimesRollers[newRolledAt].startTs
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resp = await axios.post(`${base}/api/today/aggregated`, {
|
||||
monitor: monitor,
|
||||
startTs: uptimesRollers[newRolledAt].startTs
|
||||
});
|
||||
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);
|
||||
showDailyDataModal = true;
|
||||
loadingDayData = true;
|
||||
dayIncidentsFull = [];
|
||||
setTimeout(() => {
|
||||
getToday(bar.timestamp, incidentIDs);
|
||||
}, 750);
|
||||
}
|
||||
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);
|
||||
showDailyDataModal = true;
|
||||
loadingDayData = true;
|
||||
dayIncidentsFull = [];
|
||||
setTimeout(() => {
|
||||
getToday(bar.timestamp, incidentIDs);
|
||||
}, 750);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="monitor relative grid w-full grid-cols-12 gap-2 pb-2 pt-0 md:w-[655px]">
|
||||
{#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}
|
||||
<img
|
||||
src={base + monitor.image}
|
||||
class="absolute left-6 top-6 inline h-5 w-5"
|
||||
alt={monitor.name}
|
||||
srcset=""
|
||||
/>
|
||||
{/if}
|
||||
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{monitor.name}
|
||||
</p>
|
||||
{#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" alt={monitor.name} srcset="" />
|
||||
{/if}
|
||||
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{monitor.name}
|
||||
</p>
|
||||
|
||||
<p class="mt-1 text-xs font-medium text-muted-foreground">
|
||||
{#if !!monitor.description}
|
||||
{@html monitor.description}
|
||||
{/if}
|
||||
</p>
|
||||
<div class="absolute right-14 top-5">
|
||||
<Button
|
||||
class="wiggle h-5 p-0 text-muted-foreground hover:text-primary"
|
||||
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 justify-between">
|
||||
<div class=" ">
|
||||
<div class="flex gap-x-1">
|
||||
{#if rolledAt > 0}
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="h-5 w-5 p-0"
|
||||
on:click={() => rollSummary(-1)}
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
<div class="flex text-xs font-semibold">
|
||||
<span>
|
||||
{uptimesRollers[rolledAt].text}
|
||||
</span>
|
||||
<p class="mt-1 text-xs font-medium text-muted-foreground">
|
||||
{#if !!monitor.description}
|
||||
{@html monitor.description}
|
||||
{/if}
|
||||
</p>
|
||||
<div class="absolute right-14 top-5 flex gap-x-2">
|
||||
{#if $page.data.isLoggedIn}
|
||||
<Button
|
||||
href="{base}/manage/app/monitors#{monitor.tag}"
|
||||
class=" rotate-once h-5 p-0 text-muted-foreground hover:text-primary"
|
||||
variant="link"
|
||||
rel="external"
|
||||
>
|
||||
<Settings class="h-4 w-4 " />
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
class="wiggle h-5 p-0 text-muted-foreground hover:text-primary"
|
||||
variant="link"
|
||||
on:click={(e) => {
|
||||
dispatch("show_shareMenu", {
|
||||
monitor: monitor
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Share2 class="h-4 w-4 " />
|
||||
</Button>
|
||||
{#if monitor.monitor_type === "GROUP"}
|
||||
<Button
|
||||
class="bounce-right h-5 p-0 text-muted-foreground hover:text-primary"
|
||||
variant="link"
|
||||
href="{base}?group={monitor.tag}"
|
||||
rel="external"
|
||||
>
|
||||
<ArrowRight class="arrow h-4 w-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
</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 justify-between">
|
||||
<div class=" ">
|
||||
<div class="flex gap-x-1">
|
||||
{#if rolledAt > 0}
|
||||
<Button variant="ghost" class="h-5 w-5 p-0" on:click={() => rollSummary(-1)}>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
<div class="flex text-xs font-semibold">
|
||||
<span>
|
||||
{uptimesRollers[rolledAt].text}
|
||||
</span>
|
||||
|
||||
<span class="">
|
||||
{#if rollerLoading}
|
||||
<Loader class="mx-1 -mt-0.5 inline h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<TrendingUp class="mx-1 -mt-0.5 inline h-3 w-3" />
|
||||
{/if}
|
||||
{#if isNaN(uptimesRollers[rolledAt].value)}
|
||||
<span class="text-muted-foreground">-</span>
|
||||
{:else}
|
||||
<NumberFlow
|
||||
value={uptimesRollers[rolledAt].value}
|
||||
format={{
|
||||
notation: "standard",
|
||||
minimumFractionDigits: 4,
|
||||
maximumFractionDigits: 4
|
||||
}}
|
||||
suffix="%"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if rolledAt < uptimesRollers.length - 1}
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="h-5 w-5 p-0"
|
||||
on:click={() => rollSummary(1)}
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-0.5 text-right">
|
||||
<div
|
||||
class="text-api-up truncate text-xs font-semibold text-{monitor.pageData
|
||||
.summaryColorClass}"
|
||||
>
|
||||
{monitor.pageData.summaryStatus}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="relative col-span-12 mt-1"
|
||||
use:clickOutsideAction
|
||||
on:clickoutside={(e) => {
|
||||
showDailyDataModal = false;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="daygrid90 flex min-h-[60px] justify-start overflow-x-auto overflow-y-hidden py-1"
|
||||
>
|
||||
{#each Object.entries(_90Day) as [ts, bar]}
|
||||
<a
|
||||
data-ts={ts}
|
||||
use:hoverAction
|
||||
on:hover={(e) => {
|
||||
show90Inline(e, bar);
|
||||
}}
|
||||
on:click={(e) => {
|
||||
dailyDataGetter(e, bar, incidents[ts]);
|
||||
}}
|
||||
href="#"
|
||||
class="oneline h-[34px] w-[6px]
|
||||
<span class="">
|
||||
{#if rollerLoading}
|
||||
<Loader class="mx-1 -mt-0.5 inline h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<TrendingUp class="mx-1 -mt-0.5 inline h-3 w-3" />
|
||||
{/if}
|
||||
{#if isNaN(uptimesRollers[rolledAt].value)}
|
||||
<span class="text-muted-foreground">-</span>
|
||||
{:else}
|
||||
<NumberFlow
|
||||
value={uptimesRollers[rolledAt].value}
|
||||
format={{
|
||||
notation: "standard",
|
||||
minimumFractionDigits: 4,
|
||||
maximumFractionDigits: 4
|
||||
}}
|
||||
suffix="%"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if rolledAt < uptimesRollers.length - 1}
|
||||
<Button variant="ghost" class="h-5 w-5 p-0" on:click={() => rollSummary(1)}>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-0.5 text-right">
|
||||
<div class="text-api-up truncate text-xs font-semibold text-{monitor.pageData.summaryColorClass}">
|
||||
{monitor.pageData.summaryStatus}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="relative col-span-12 mt-1"
|
||||
use:clickOutsideAction
|
||||
on:clickoutside={(e) => {
|
||||
showDailyDataModal = false;
|
||||
}}
|
||||
>
|
||||
<div class="daygrid90 flex min-h-[60px] justify-start overflow-x-auto overflow-y-hidden py-1">
|
||||
{#each Object.entries(_90Day) as [ts, bar]}
|
||||
<a
|
||||
data-ts={ts}
|
||||
use:hoverAction
|
||||
on:hover={(e) => {
|
||||
show90Inline(e, bar);
|
||||
}}
|
||||
on:click={(e) => {
|
||||
dailyDataGetter(e, bar, incidents[ts]);
|
||||
}}
|
||||
href="#"
|
||||
class="oneline h-[34px] w-[6px]
|
||||
{bar.border ? 'opacity-100' : 'opacity-20'} pb-1"
|
||||
>
|
||||
<div
|
||||
class="oneline-in h-[30px] bg-{bar.cssClass} mx-auto w-[4px] rounded-{monitor.pageData.barRoundness.toUpperCase() ==
|
||||
'SHARP'
|
||||
? 'none'
|
||||
: 'sm'}"
|
||||
></div>
|
||||
{#if !!incidents[ts]}
|
||||
<div
|
||||
style="transition-delay: {Math.floor(
|
||||
Math.random() * (1500 - 500 + 1)
|
||||
) + 500}ms;"
|
||||
class="bg-api-{incidents[
|
||||
ts
|
||||
].monitor_impact.toLowerCase()} comein absolute -bottom-[3px] left-[1px] h-[4px] w-[4px] rounded-full"
|
||||
></div>
|
||||
{/if}
|
||||
</a>
|
||||
{#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
|
||||
)}
|
||||
-
|
||||
{bar.summaryStatus}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if showDailyDataModal}
|
||||
<div
|
||||
transition:slide={{ direction: "bottom" }}
|
||||
class="absolute -left-2 top-10 z-10 mx-auto rounded-sm border bg-card 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>
|
||||
>
|
||||
<div
|
||||
class="oneline-in h-[30px] bg-{bar.cssClass} mx-auto w-[4px] rounded-{monitor.pageData.barRoundness.toUpperCase() ==
|
||||
'SHARP'
|
||||
? 'none'
|
||||
: 'sm'}"
|
||||
></div>
|
||||
{#if !!incidents[ts]}
|
||||
<div
|
||||
style="transition-delay: {Math.floor(Math.random() * (1500 - 500 + 1)) + 500}ms;"
|
||||
class="bg-api-{incidents[
|
||||
ts
|
||||
].monitor_impact.toLowerCase()} comein absolute -bottom-[3px] left-[1px] h-[4px] w-[4px] rounded-full"
|
||||
></div>
|
||||
{/if}
|
||||
</a>
|
||||
{#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)}
|
||||
-
|
||||
{bar.summaryStatus}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if showDailyDataModal}
|
||||
<div
|
||||
transition:slide={{ direction: "bottom" }}
|
||||
class="absolute -left-2 top-10 z-10 mx-auto rounded-sm border bg-card 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
|
||||
)}
|
||||
</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>
|
||||
{f(new Date(bar.timestamp * 1000), "hh:mm a", selectedLang)}
|
||||
</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>
|
||||
|
||||
@@ -1,64 +1,62 @@
|
||||
<script>
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
||||
import { Languages, Menu } from "lucide-svelte";
|
||||
import { base } from "$app/paths";
|
||||
import { analyticsEvent } from "$lib/boringOne";
|
||||
export let data;
|
||||
let defaultPattern = data.site?.pattern || "squares";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
||||
import { Languages, Menu } from "lucide-svelte";
|
||||
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";
|
||||
</script>
|
||||
|
||||
<div class="{defaultPattern}-pattern"></div>
|
||||
|
||||
<header class="blurry-bg 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 href={data.site.home ? data.site.home : base} class="mr-6 flex items-center space-x-2">
|
||||
{#if data.site.logo}
|
||||
<img src={base + data.site.logo} class="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
|
||||
href={navItem.url}
|
||||
class="flex rounded-md px-3 py-2 text-card-foreground transition-all ease-linear hover:bg-background"
|
||||
on:click={() => analyticsEvent("nav", navItem.name)}
|
||||
>
|
||||
{#if navItem.iconURL}
|
||||
<img
|
||||
src={base + navItem.iconURL}
|
||||
class="mr-1.5 mt-0.5 inline h-4"
|
||||
alt={navItem.name}
|
||||
/>
|
||||
{/if}
|
||||
<span>{navItem.name}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<DropdownMenu.Root class="">
|
||||
<DropdownMenu.Trigger class="mr-2 flex md:hidden">
|
||||
<Button variant="outline" size="sm">
|
||||
<Menu size={14} />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
{#each data.site.nav as navItem}
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.Item>
|
||||
<a href={navItem.url}> {navItem.name} </a>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<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("nav", 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>
|
||||
<DropdownMenu.Root class="">
|
||||
<DropdownMenu.Trigger class="mr-2 flex md:hidden">
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,293 +1,289 @@
|
||||
<script>
|
||||
import { Share2, Link, CopyCheck, Code, TrendingUp, Percent, Loader } from "lucide-svelte";
|
||||
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 { Share2, Link, CopyCheck, Code, TrendingUp, Percent, Loader, ExternalLink } from "lucide-svelte";
|
||||
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;
|
||||
export let selectedLang = "en";
|
||||
let theme = "light";
|
||||
let copiedLink = false;
|
||||
let embedType = "js";
|
||||
let copiedEmbed = false;
|
||||
let copiedBadgeStatus = false;
|
||||
let copiedBadgeUptime = false;
|
||||
let copiedBadgeDotStandard = false;
|
||||
let copiedBadgeDotPing = false;
|
||||
export let monitor;
|
||||
export let lang;
|
||||
export let selectedLang = "en";
|
||||
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 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 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 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 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
|
||||
});
|
||||
let pathMonitorBadgeDot;
|
||||
function copyDotStandard() {
|
||||
analyticsEvent("monitor_svg_standard_copied", {
|
||||
tag: monitor.tag
|
||||
});
|
||||
|
||||
navigator.clipboard.writeText(pathMonitorBadgeDot);
|
||||
copiedBadgeDotStandard = true;
|
||||
setTimeout(function () {
|
||||
copiedBadgeDotStandard = false;
|
||||
}, 1500);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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
|
||||
function copyScriptTagToClipboard() {
|
||||
//get domain with port number
|
||||
|
||||
analyticsEvent("monitor_embed_copied", {
|
||||
tag: monitor.tag,
|
||||
type: embedType
|
||||
});
|
||||
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>";
|
||||
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);
|
||||
}
|
||||
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
|
||||
});
|
||||
});
|
||||
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}
|
||||
<img
|
||||
src={monitor.image.startsWith("/") ? base + monitor.image : monitor.image}
|
||||
class="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 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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -3,12 +3,12 @@ import notification from "./notification/notif.js";
|
||||
|
||||
import moment from "moment";
|
||||
import {
|
||||
GetAllSiteData,
|
||||
GetAllTriggers,
|
||||
CreateIncident,
|
||||
AddIncidentComment,
|
||||
AddIncidentMonitor,
|
||||
InsertNewAlert
|
||||
GetAllSiteData,
|
||||
GetAllTriggers,
|
||||
CreateIncident,
|
||||
AddIncidentComment,
|
||||
AddIncidentMonitor,
|
||||
InsertNewAlert,
|
||||
} from "./controllers/controller.js";
|
||||
|
||||
import db from "./db/db.js";
|
||||
@@ -17,197 +17,184 @@ const TRIGGERED = "TRIGGERED";
|
||||
const RESOLVED = "RESOLVED";
|
||||
|
||||
async function createJSONCommonAlert(monitor, config, alert, severity) {
|
||||
let siteData = await GetAllSiteData();
|
||||
let siteURL = siteData.siteURL;
|
||||
let id = monitor.tag + "-" + alert.id;
|
||||
let alert_name = monitor.name + " " + alert.monitor_status;
|
||||
let source = "Kener";
|
||||
let timestamp = new Date().toISOString();
|
||||
let description = config.description || "Monitor has failed";
|
||||
let status = alert.alert_status;
|
||||
let details = {
|
||||
metric: monitor.name,
|
||||
current_value: alert.health_checks,
|
||||
threshold: config.failureThreshold
|
||||
};
|
||||
let actions = [
|
||||
{
|
||||
text: "View Monitor",
|
||||
url: siteURL + "?monitor=" + monitor.tag
|
||||
}
|
||||
];
|
||||
return {
|
||||
id,
|
||||
alert_name,
|
||||
severity,
|
||||
status,
|
||||
source,
|
||||
timestamp,
|
||||
description,
|
||||
details,
|
||||
actions
|
||||
};
|
||||
let siteData = await GetAllSiteData();
|
||||
let siteURL = siteData.siteURL;
|
||||
let id = monitor.tag + "-" + alert.id;
|
||||
let alert_name = monitor.name + " " + alert.monitor_status;
|
||||
let source = "Kener";
|
||||
let timestamp = new Date().toISOString();
|
||||
let description = config.description || "Monitor has failed";
|
||||
let status = alert.alert_status;
|
||||
let details = {
|
||||
metric: monitor.name,
|
||||
current_value: alert.health_checks,
|
||||
threshold: config.failureThreshold,
|
||||
};
|
||||
let actions = [
|
||||
{
|
||||
text: "View Monitor",
|
||||
url: siteURL + "?monitor=" + monitor.tag,
|
||||
},
|
||||
];
|
||||
return {
|
||||
id,
|
||||
alert_name,
|
||||
severity,
|
||||
status,
|
||||
source,
|
||||
timestamp,
|
||||
description,
|
||||
details,
|
||||
actions,
|
||||
};
|
||||
}
|
||||
|
||||
async function createNewIncident(monitor, alert, commonData) {
|
||||
let startDateTime = moment(alert.created_at).unix();
|
||||
let payload = {
|
||||
start_date_time: startDateTime,
|
||||
title: commonData.alert_name
|
||||
};
|
||||
let startDateTime = moment(alert.created_at).unix();
|
||||
let payload = {
|
||||
start_date_time: startDateTime,
|
||||
title: commonData.alert_name,
|
||||
};
|
||||
|
||||
let update = commonData.description;
|
||||
update =
|
||||
update +
|
||||
`. Monitor Name: ${monitor.name}, Incident Status: ${commonData.status}, Severity: ${commonData.severity}, Monitor Status: ${alert.monitor_status}, Monitor Health Checks: ${alert.health_checks}, Monitor Failure Threshold: ${commonData.details.threshold}, Visit - ${commonData.actions[0].url}`;
|
||||
let update = commonData.description;
|
||||
update =
|
||||
update +
|
||||
`<div style="line-height: 1.5;">
|
||||
<p><strong>Monitor Name:</strong> ${monitor.name}</p>
|
||||
<p><strong>Incident Status:</strong> ${commonData.status}</p>
|
||||
<p><strong>Severity:</strong> ${commonData.severity}</p>
|
||||
<p><strong>Monitor Status:</strong> ${alert.monitor_status}</p>
|
||||
<p><strong>Monitor Health Checks:</strong> ${alert.health_checks}</p>
|
||||
<p><strong>Monitor Failure Threshold:</strong> ${commonData.details.threshold}</p>
|
||||
<p><strong>Visit:</strong> <a href="${commonData.actions[0].url}">${commonData.actions[0].url}</a></p>
|
||||
</div>`;
|
||||
|
||||
let newIncident = await CreateIncident(payload);
|
||||
let newIncident = await CreateIncident(payload);
|
||||
|
||||
//add update to incident
|
||||
await AddIncidentComment(newIncident.incident_id, update, "INVESTIGATING", startDateTime);
|
||||
//add update to incident
|
||||
await AddIncidentComment(newIncident.incident_id, update, "INVESTIGATING", startDateTime);
|
||||
|
||||
//add monitor to incident
|
||||
await AddIncidentMonitor(newIncident.incident_id, monitor.tag, alert.monitor_status);
|
||||
//add monitor to incident
|
||||
await AddIncidentMonitor(newIncident.incident_id, monitor.tag, alert.monitor_status);
|
||||
|
||||
return newIncident;
|
||||
return newIncident;
|
||||
}
|
||||
|
||||
async function closeIncident(alert, comment) {
|
||||
let incident_id = alert.incident_number;
|
||||
return await AddIncidentComment(
|
||||
incident_id,
|
||||
comment,
|
||||
"RESOLVED",
|
||||
moment(alert.updated_at).unix()
|
||||
);
|
||||
let incident_id = alert.incident_number;
|
||||
return await AddIncidentComment(incident_id, comment, "RESOLVED", moment(alert.updated_at).unix());
|
||||
}
|
||||
|
||||
function createClosureComment(alert, commonJSON) {
|
||||
let comment = "The incident has been auto resolved";
|
||||
let downtimeDuration = moment(alert.updated_at).diff(moment(alert.created_at), "minutes");
|
||||
comment = comment + `, Total downtime: ` + downtimeDuration + ` minutes`;
|
||||
return comment;
|
||||
let comment = "The incident has been auto resolved";
|
||||
let downtimeDuration = moment(alert.updated_at).diff(moment(alert.created_at), "minutes");
|
||||
comment = comment + `, Total downtime: ` + downtimeDuration + ` minutes`;
|
||||
return comment;
|
||||
}
|
||||
|
||||
async function alerting(m) {
|
||||
let monitor = await db.getMonitorByTag(m.tag);
|
||||
let siteData = await GetAllSiteData();
|
||||
const triggers = await GetAllTriggers({
|
||||
status: "ACTIVE"
|
||||
});
|
||||
const triggerObj = {};
|
||||
if (!!monitor.down_trigger) {
|
||||
triggerObj.down_trigger = JSON.parse(monitor.down_trigger);
|
||||
}
|
||||
if (!!monitor.degraded_trigger) {
|
||||
triggerObj.degraded_trigger = JSON.parse(monitor.degraded_trigger);
|
||||
}
|
||||
let monitor = await db.getMonitorByTag(m.tag);
|
||||
let siteData = await GetAllSiteData();
|
||||
const triggers = await GetAllTriggers({
|
||||
status: "ACTIVE",
|
||||
});
|
||||
const triggerObj = {};
|
||||
if (!!monitor.down_trigger) {
|
||||
triggerObj.down_trigger = JSON.parse(monitor.down_trigger);
|
||||
}
|
||||
if (!!monitor.degraded_trigger) {
|
||||
triggerObj.degraded_trigger = JSON.parse(monitor.degraded_trigger);
|
||||
}
|
||||
|
||||
for (const key in triggerObj) {
|
||||
if (Object.prototype.hasOwnProperty.call(triggerObj, key)) {
|
||||
const alertConfig = triggerObj[key];
|
||||
const monitor_status = alertConfig.trigger_type;
|
||||
for (const key in triggerObj) {
|
||||
if (Object.prototype.hasOwnProperty.call(triggerObj, key)) {
|
||||
const alertConfig = triggerObj[key];
|
||||
const monitor_status = alertConfig.trigger_type;
|
||||
|
||||
const failureThreshold = alertConfig.failureThreshold;
|
||||
const successThreshold = alertConfig.successThreshold;
|
||||
const monitor_tag = monitor.tag;
|
||||
const alertingChannels = alertConfig.triggers; //array of numbers of trigger ids
|
||||
const createIncident = alertConfig.createIncident === "YES";
|
||||
const allMonitorClients = [];
|
||||
const sendTrigger = alertConfig.active;
|
||||
const severity = alertConfig.severity;
|
||||
if (!sendTrigger) {
|
||||
continue;
|
||||
}
|
||||
if (alertingChannels.length > 0) {
|
||||
for (let i = 0; i < alertingChannels.length; i++) {
|
||||
const triggerID = alertingChannels[i];
|
||||
const trigger = triggers.find((c) => c.id === triggerID);
|
||||
if (!trigger) {
|
||||
console.error(
|
||||
`Triggers ${triggerID} not found in server triggers for monitor ${monitor_tag}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (trigger.trigger_status !== "ACTIVE") {
|
||||
console.error(`Triggers ${triggerID} is not active`);
|
||||
continue;
|
||||
}
|
||||
const notificationClient = new notification(trigger, siteData, monitor);
|
||||
allMonitorClients.push(notificationClient);
|
||||
}
|
||||
}
|
||||
const failureThreshold = alertConfig.failureThreshold;
|
||||
const successThreshold = alertConfig.successThreshold;
|
||||
const monitor_tag = monitor.tag;
|
||||
const alertingChannels = alertConfig.triggers; //array of numbers of trigger ids
|
||||
const createIncident = alertConfig.createIncident === "YES";
|
||||
const allMonitorClients = [];
|
||||
const sendTrigger = alertConfig.active;
|
||||
const severity = alertConfig.severity;
|
||||
if (!sendTrigger) {
|
||||
continue;
|
||||
}
|
||||
if (alertingChannels.length > 0) {
|
||||
for (let i = 0; i < alertingChannels.length; i++) {
|
||||
const triggerID = alertingChannels[i];
|
||||
const trigger = triggers.find((c) => c.id === triggerID);
|
||||
if (!trigger) {
|
||||
console.error(`Triggers ${triggerID} not found in server triggers for monitor ${monitor_tag}`);
|
||||
continue;
|
||||
}
|
||||
if (trigger.trigger_status !== "ACTIVE") {
|
||||
console.error(`Triggers ${triggerID} is not active`);
|
||||
continue;
|
||||
}
|
||||
const notificationClient = new notification(trigger, siteData, monitor);
|
||||
allMonitorClients.push(notificationClient);
|
||||
}
|
||||
}
|
||||
|
||||
let isAffected = await db.consecutivelyStatusFor(
|
||||
monitor_tag,
|
||||
monitor_status,
|
||||
failureThreshold
|
||||
);
|
||||
let alertExists = await db.alertExists(monitor_tag, monitor_status, TRIGGERED);
|
||||
let activeAlert = null;
|
||||
if (alertExists) {
|
||||
activeAlert = await db.getActiveAlert(monitor_tag, monitor_status, TRIGGERED);
|
||||
}
|
||||
if (isAffected && !alertExists) {
|
||||
activeAlert = await InsertNewAlert({
|
||||
monitor_tag: monitor_tag,
|
||||
monitor_status: monitor_status,
|
||||
alert_status: TRIGGERED,
|
||||
health_checks: failureThreshold
|
||||
});
|
||||
let commonJSON = await createJSONCommonAlert(
|
||||
monitor,
|
||||
alertConfig,
|
||||
activeAlert,
|
||||
severity
|
||||
);
|
||||
let isAffected = await db.consecutivelyStatusFor(monitor_tag, monitor_status, failureThreshold);
|
||||
let alertExists = await db.alertExists(monitor_tag, monitor_status, TRIGGERED);
|
||||
let activeAlert = null;
|
||||
if (alertExists) {
|
||||
activeAlert = await db.getActiveAlert(monitor_tag, monitor_status, TRIGGERED);
|
||||
}
|
||||
if (isAffected && !alertExists) {
|
||||
activeAlert = await InsertNewAlert({
|
||||
monitor_tag: monitor_tag,
|
||||
monitor_status: monitor_status,
|
||||
alert_status: TRIGGERED,
|
||||
health_checks: failureThreshold,
|
||||
});
|
||||
let commonJSON = await createJSONCommonAlert(monitor, alertConfig, activeAlert, severity);
|
||||
|
||||
if (allMonitorClients.length > 0) {
|
||||
for (let i = 0; i < allMonitorClients.length; i++) {
|
||||
const client = allMonitorClients[i];
|
||||
client.send(commonJSON);
|
||||
}
|
||||
}
|
||||
if (createIncident) {
|
||||
let incident = await createNewIncident(monitor, activeAlert, commonJSON);
|
||||
if (allMonitorClients.length > 0) {
|
||||
for (let i = 0; i < allMonitorClients.length; i++) {
|
||||
const client = allMonitorClients[i];
|
||||
client.send(commonJSON);
|
||||
}
|
||||
}
|
||||
if (createIncident) {
|
||||
let incident = await createNewIncident(monitor, activeAlert, commonJSON);
|
||||
|
||||
if (!!incident) {
|
||||
//send incident to incident channel
|
||||
await db.addIncidentNumberToAlert(activeAlert.id, incident.incident_id);
|
||||
}
|
||||
}
|
||||
} else if (isAffected && alertExists) {
|
||||
await db.incrementAlertHealthChecks(activeAlert.id);
|
||||
} else if (!isAffected && alertExists) {
|
||||
let isUp = await db.consecutivelyStatusFor(monitor_tag, "UP", successThreshold);
|
||||
if (isUp) {
|
||||
await db.updateAlertStatus(activeAlert.id, RESOLVED);
|
||||
activeAlert.alert_status = RESOLVED;
|
||||
let commonJSON = await createJSONCommonAlert(
|
||||
monitor,
|
||||
alertConfig,
|
||||
activeAlert,
|
||||
severity
|
||||
);
|
||||
if (allMonitorClients.length > 0) {
|
||||
for (let i = 0; i < allMonitorClients.length; i++) {
|
||||
const client = allMonitorClients[i];
|
||||
client.send(commonJSON);
|
||||
}
|
||||
}
|
||||
if (!!activeAlert.incident_number) {
|
||||
let comment = createClosureComment(activeAlert, commonJSON);
|
||||
if (!!incident) {
|
||||
//send incident to incident channel
|
||||
await db.addIncidentNumberToAlert(activeAlert.id, incident.incident_id);
|
||||
}
|
||||
}
|
||||
} else if (isAffected && alertExists) {
|
||||
await db.incrementAlertHealthChecks(activeAlert.id);
|
||||
} else if (!isAffected && alertExists) {
|
||||
let isUp = await db.consecutivelyStatusFor(monitor_tag, "UP", successThreshold);
|
||||
if (isUp) {
|
||||
await db.updateAlertStatus(activeAlert.id, RESOLVED);
|
||||
activeAlert.alert_status = RESOLVED;
|
||||
let commonJSON = await createJSONCommonAlert(monitor, alertConfig, activeAlert, severity);
|
||||
if (allMonitorClients.length > 0) {
|
||||
for (let i = 0; i < allMonitorClients.length; i++) {
|
||||
const client = allMonitorClients[i];
|
||||
client.send(commonJSON);
|
||||
}
|
||||
}
|
||||
if (!!activeAlert.incident_number) {
|
||||
let comment = createClosureComment(activeAlert, commonJSON);
|
||||
|
||||
try {
|
||||
await closeIncident(activeAlert, comment);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await closeIncident(activeAlert, comment);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default alerting;
|
||||
|
||||
@@ -8,92 +8,115 @@ const SITE = "./config/site.yaml";
|
||||
const UP = "UP";
|
||||
const DOWN = "DOWN";
|
||||
const DEGRADED = "DEGRADED";
|
||||
const NO_DATA = "NO_DATA";
|
||||
const API_TIMEOUT = 10 * 1000; // 10 seconds
|
||||
const AnalyticsProviders = {
|
||||
GA: "https://unpkg.com/@analytics/google-analytics@1.0.7/dist/@analytics/google-analytics.min.js",
|
||||
AMPLITUDE: "https://unpkg.com/@analytics/amplitude@0.1.3/dist/@analytics/amplitude.min.js",
|
||||
MIXPANEL: "https://unpkg.com/@analytics/mixpanel@0.4.0/dist/@analytics/mixpanel.min.js"
|
||||
GA: "https://unpkg.com/@analytics/google-analytics@1.0.7/dist/@analytics/google-analytics.min.js",
|
||||
AMPLITUDE: "https://unpkg.com/@analytics/amplitude@0.1.3/dist/@analytics/amplitude.min.js",
|
||||
MIXPANEL: "https://unpkg.com/@analytics/mixpanel@0.4.0/dist/@analytics/mixpanel.min.js",
|
||||
};
|
||||
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
|
||||
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,
|
||||
};
|
||||
// Export the constants
|
||||
export { MONITOR, UP, DOWN, SITE, DEGRADED, API_TIMEOUT, ENV, AnalyticsProviders, AllRecordTypes };
|
||||
const REALTIME = "realtime";
|
||||
const TIMEOUT = "timeout";
|
||||
const ERROR = "error";
|
||||
const MANUAL = "manual";
|
||||
const WEBHOOK = "webhook";
|
||||
|
||||
export {
|
||||
MONITOR,
|
||||
UP,
|
||||
DOWN,
|
||||
SITE,
|
||||
DEGRADED,
|
||||
API_TIMEOUT,
|
||||
ENV,
|
||||
AnalyticsProviders,
|
||||
AllRecordTypes,
|
||||
REALTIME,
|
||||
TIMEOUT,
|
||||
ERROR,
|
||||
MANUAL,
|
||||
NO_DATA,
|
||||
WEBHOOK,
|
||||
};
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
// @ts-nocheck
|
||||
import axios from "axios";
|
||||
import { Ping, ExtractIPv6HostAndPort, TCP } from "./ping.js";
|
||||
import { UP, DOWN, DEGRADED } from "./constants.js";
|
||||
import {
|
||||
GetMinuteStartNowTimestampUTC,
|
||||
ReplaceAllOccurrences,
|
||||
GetRequiredSecrets,
|
||||
Wait,
|
||||
} from "./tool.js";
|
||||
import { UP, DOWN, DEGRADED, REALTIME, TIMEOUT, ERROR, MANUAL } from "./constants.js";
|
||||
import Service from "./services/service.js";
|
||||
import { GetMinuteStartNowTimestampUTC, ReplaceAllOccurrences, GetRequiredSecrets, Wait } from "./tool.js";
|
||||
|
||||
import alerting from "./alerting.js";
|
||||
import Queue from "queue";
|
||||
@@ -19,11 +15,6 @@ import DNSResolver from "./dns.js";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const REALTIME = "realtime";
|
||||
const TIMEOUT = "timeout";
|
||||
const ERROR = "error";
|
||||
const MANUAL = "manual";
|
||||
|
||||
const alertingQueue = new Queue({
|
||||
concurrency: 10, // Number of tasks that can run concurrently
|
||||
timeout: 10000, // Timeout in ms after which a task will be considered as failed (optional)
|
||||
@@ -31,69 +22,14 @@ const alertingQueue = new Queue({
|
||||
});
|
||||
const apiQueue = new Queue({
|
||||
concurrency: 10, // Number of tasks that can run concurrently
|
||||
timeout: 10000, // Timeout in ms after which a task will be considered as failed (optional)
|
||||
timeout: 2 * 60 * 1000, // Timeout in ms after which a task will be considered as failed (optional)
|
||||
autostart: true, // Automatically start the queue (optional)
|
||||
});
|
||||
|
||||
const defaultEval = `(async function (statusCode, responseTime, responseData) {
|
||||
let statusCodeShort = Math.floor(statusCode/100);
|
||||
if(statusCode == 429 || (statusCodeShort >=2 && statusCodeShort <= 3)) {
|
||||
return {
|
||||
status: 'UP',
|
||||
latency: responseTime,
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'DOWN',
|
||||
latency: responseTime,
|
||||
}
|
||||
})`;
|
||||
|
||||
const defaultPingEval = `(async function (responseDataBase64) {
|
||||
let arrayOfPings = JSON.parse(atob(responseDataBase64));
|
||||
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,
|
||||
}
|
||||
})`;
|
||||
const defaultTcpEval = `(async function (responseDataBase64) {
|
||||
let arrayOfPings = JSON.parse(atob(responseDataBase64));
|
||||
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
|
||||
return acc + ping.latency;
|
||||
}, 0);
|
||||
|
||||
let alive = arrayOfPings.reduce((acc, ping) => {
|
||||
if (ping.status === "open") {
|
||||
return acc && true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}, true);
|
||||
|
||||
return {
|
||||
status: alive ? 'UP' : 'DOWN',
|
||||
latency: latencyTotal / arrayOfPings.length,
|
||||
}
|
||||
})`;
|
||||
|
||||
async function manualIncident(monitor) {
|
||||
let startTs = GetMinuteStartNowTimestampUTC();
|
||||
let incidentArr = await db.getIncidentsByMonitorTagRealtime(
|
||||
monitor.tag,
|
||||
startTs,
|
||||
);
|
||||
let maintenanceArr = await db.getMaintenanceByMonitorTagRealtime(
|
||||
monitor.tag,
|
||||
startTs,
|
||||
);
|
||||
let incidentArr = await db.getIncidentsByMonitorTagRealtime(monitor.tag, startTs);
|
||||
let maintenanceArr = await db.getMaintenanceByMonitorTagRealtime(monitor.tag, startTs);
|
||||
|
||||
let impactArr = incidentArr.concat(maintenanceArr);
|
||||
|
||||
@@ -105,11 +41,7 @@ async function manualIncident(monitor) {
|
||||
for (let i = 0; i < impactArr.length; i++) {
|
||||
const element = impactArr[i];
|
||||
|
||||
let autoIncidents = await db.getActiveAlertIncident(
|
||||
monitor.tag,
|
||||
element.monitor_impact,
|
||||
element.id,
|
||||
);
|
||||
let autoIncidents = await db.getActiveAlertIncident(monitor.tag, element.monitor_impact, element.id);
|
||||
|
||||
if (!!autoIncidents) {
|
||||
continue;
|
||||
@@ -138,297 +70,23 @@ async function manualIncident(monitor) {
|
||||
return manualData;
|
||||
}
|
||||
|
||||
const tcpCall = async (hosts, tcpEval, tag) => {
|
||||
let arrayOfPings = [];
|
||||
for (let i = 0; i < hosts.length; i++) {
|
||||
const host = hosts[i];
|
||||
arrayOfPings.push(await TCP(host.type, host.host, host.port, host.timeout));
|
||||
}
|
||||
let respBase64 = Buffer.from(JSON.stringify(arrayOfPings)).toString("base64");
|
||||
|
||||
let evalResp = undefined;
|
||||
|
||||
try {
|
||||
evalResp = await eval(tcpEval + `("${respBase64}")`);
|
||||
} catch (error) {
|
||||
console.log(`Error in tcpEval for ${tag}`, error.message);
|
||||
}
|
||||
//reduce to get the status
|
||||
return {
|
||||
status: evalResp.status,
|
||||
latency: evalResp.latency,
|
||||
type: REALTIME,
|
||||
};
|
||||
};
|
||||
const pingCall = async (hosts, pingEval, tag) => {
|
||||
if (hosts === undefined) {
|
||||
console.log(
|
||||
"Hosts is undefined. The ping monitor has changed in version 3.0.10. Please update your monitor with tag",
|
||||
tag,
|
||||
);
|
||||
return {
|
||||
status: DOWN,
|
||||
latency: 0,
|
||||
type: ERROR,
|
||||
};
|
||||
}
|
||||
let arrayOfPings = [];
|
||||
for (let i = 0; i < hosts.length; i++) {
|
||||
const host = hosts[i];
|
||||
arrayOfPings.push(
|
||||
await Ping(host.type, host.host, host.timeout, host.count),
|
||||
);
|
||||
}
|
||||
let respBase64 = Buffer.from(JSON.stringify(arrayOfPings)).toString("base64");
|
||||
|
||||
let evalResp = undefined;
|
||||
|
||||
try {
|
||||
evalResp = await eval(pingEval + `("${respBase64}")`);
|
||||
} catch (error) {
|
||||
console.log(`Error in pingEval for ${tag}`, error.message);
|
||||
}
|
||||
//reduce to get the status
|
||||
return {
|
||||
status: evalResp.status,
|
||||
latency: evalResp.latency,
|
||||
type: REALTIME,
|
||||
};
|
||||
};
|
||||
const apiCall = async (
|
||||
envSecrets,
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
timeout,
|
||||
monitorEval,
|
||||
tag,
|
||||
) => {
|
||||
let axiosHeaders = {};
|
||||
axiosHeaders["User-Agent"] = "Kener/3.0.2";
|
||||
axiosHeaders["Accept"] = "*/*";
|
||||
const start = Date.now();
|
||||
//replace all secrets
|
||||
for (let i = 0; i < envSecrets.length; i++) {
|
||||
const secret = envSecrets[i];
|
||||
if (!!body) {
|
||||
body = ReplaceAllOccurrences(body, secret.find, secret.replace);
|
||||
}
|
||||
if (!!url) {
|
||||
url = ReplaceAllOccurrences(url, secret.find, secret.replace);
|
||||
}
|
||||
if (!!headers) {
|
||||
headers = ReplaceAllOccurrences(headers, secret.find, secret.replace);
|
||||
}
|
||||
}
|
||||
if (!!headers) {
|
||||
headers = JSON.parse(headers);
|
||||
headers = headers.reduce((acc, header) => {
|
||||
acc[header.key] = header.value;
|
||||
return acc;
|
||||
}, {});
|
||||
axiosHeaders = { ...axiosHeaders, ...headers };
|
||||
}
|
||||
|
||||
const options = {
|
||||
method: method,
|
||||
headers: headers,
|
||||
timeout: timeout,
|
||||
transformResponse: (r) => r,
|
||||
};
|
||||
if (!!headers) {
|
||||
options.headers = headers;
|
||||
}
|
||||
if (!!body) {
|
||||
options.data = body;
|
||||
}
|
||||
let statusCode = 500;
|
||||
let latency = 0;
|
||||
let resp = "";
|
||||
let timeoutError = false;
|
||||
try {
|
||||
let data = await axios(url, options);
|
||||
statusCode = data.status;
|
||||
resp = data.data;
|
||||
} catch (err) {
|
||||
console.log(`Error in apiCall ${tag}`, err.message);
|
||||
if (
|
||||
err.message.startsWith("timeout of") &&
|
||||
err.message.endsWith("exceeded")
|
||||
) {
|
||||
timeoutError = true;
|
||||
}
|
||||
if (err.response !== undefined && err.response.status !== undefined) {
|
||||
statusCode = err.response.status;
|
||||
}
|
||||
if (err.response !== undefined && err.response.data !== undefined) {
|
||||
resp = err.response.data;
|
||||
} else {
|
||||
resp = JSON.stringify(resp);
|
||||
}
|
||||
} finally {
|
||||
const end = Date.now();
|
||||
latency = end - start;
|
||||
if (resp === undefined || resp === null) {
|
||||
resp = "";
|
||||
}
|
||||
}
|
||||
resp = Buffer.from(resp).toString("base64");
|
||||
|
||||
let evalResp = undefined;
|
||||
|
||||
try {
|
||||
evalResp = await eval(
|
||||
monitorEval + `(${statusCode}, ${latency}, "${resp}")`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.log(`Error in monitorEval for ${tag}`, error.message);
|
||||
}
|
||||
|
||||
if (evalResp === undefined || evalResp === null) {
|
||||
evalResp = {
|
||||
status: DOWN,
|
||||
latency: latency,
|
||||
type: ERROR,
|
||||
};
|
||||
} else if (
|
||||
evalResp.status === undefined ||
|
||||
evalResp.status === null ||
|
||||
[UP, DOWN, DEGRADED].indexOf(evalResp.status) === -1
|
||||
) {
|
||||
evalResp = {
|
||||
status: DOWN,
|
||||
latency: latency,
|
||||
type: ERROR,
|
||||
};
|
||||
} else {
|
||||
evalResp.type = REALTIME;
|
||||
}
|
||||
|
||||
let toWrite = {
|
||||
status: DOWN,
|
||||
latency: latency,
|
||||
type: ERROR,
|
||||
};
|
||||
if (evalResp.status !== undefined && evalResp.status !== null) {
|
||||
toWrite.status = evalResp.status;
|
||||
}
|
||||
if (evalResp.latency !== undefined && evalResp.latency !== null) {
|
||||
toWrite.latency = evalResp.latency;
|
||||
}
|
||||
if (evalResp.type !== undefined && evalResp.type !== null) {
|
||||
toWrite.type = evalResp.type;
|
||||
}
|
||||
if (timeoutError) {
|
||||
toWrite.type = TIMEOUT;
|
||||
}
|
||||
|
||||
return toWrite;
|
||||
};
|
||||
|
||||
async function dsnChecker(dnsResolver, host, recordType, matchType, values) {
|
||||
try {
|
||||
let queryStartTime = Date.now();
|
||||
let dnsRes = await dnsResolver.getRecord(host, recordType);
|
||||
let latency = Date.now() - queryStartTime;
|
||||
|
||||
if (dnsRes[recordType] === undefined) {
|
||||
return {
|
||||
status: DOWN,
|
||||
latency: latency,
|
||||
type: REALTIME,
|
||||
};
|
||||
}
|
||||
let data = dnsRes[recordType];
|
||||
let dnsData = data.map((d) => d.data);
|
||||
if (matchType === "ALL") {
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
if (dnsData.indexOf(values[i].trim()) === -1) {
|
||||
return {
|
||||
status: DOWN,
|
||||
latency: latency,
|
||||
type: REALTIME,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: UP,
|
||||
latency: latency,
|
||||
type: REALTIME,
|
||||
};
|
||||
} else if (matchType === "ANY") {
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
if (dnsData.indexOf(values[i].trim()) !== -1) {
|
||||
return {
|
||||
status: UP,
|
||||
latency: latency,
|
||||
type: REALTIME,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: DOWN,
|
||||
latency: latency,
|
||||
type: REALTIME,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error in dnsChecker", error);
|
||||
return {
|
||||
status: DOWN,
|
||||
latency: 0,
|
||||
type: REALTIME,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const Minuter = async (monitor) => {
|
||||
let realTimeData = {};
|
||||
let manualData = {};
|
||||
|
||||
const startOfMinute = GetMinuteStartNowTimestampUTC();
|
||||
const serviceClient = new Service(monitor);
|
||||
if (monitor.monitor_type === "API") {
|
||||
let envSecrets = GetRequiredSecrets(
|
||||
`${monitor.type_data.url} ${monitor.type_data.body} ${JSON.stringify(monitor.type_data.headers)}`,
|
||||
);
|
||||
|
||||
if (monitor.type_data.eval === "") {
|
||||
monitor.type_data.eval = defaultEval;
|
||||
}
|
||||
|
||||
let apiResponse = await apiCall(
|
||||
envSecrets,
|
||||
monitor.type_data.url,
|
||||
monitor.type_data.method,
|
||||
JSON.stringify(monitor.type_data.headers),
|
||||
monitor.type_data.body,
|
||||
monitor.type_data.timeout,
|
||||
monitor.type_data.eval,
|
||||
monitor.tag,
|
||||
);
|
||||
let apiResponse = await serviceClient.execute();
|
||||
|
||||
realTimeData[startOfMinute] = apiResponse;
|
||||
|
||||
//if timeout, retry after 500ms
|
||||
if (apiResponse.type === TIMEOUT) {
|
||||
apiQueue.push(async (cb) => {
|
||||
await Wait(500); //wait for 500ms
|
||||
console.log(
|
||||
"Retrying api call for " +
|
||||
monitor.name +
|
||||
" at " +
|
||||
startOfMinute +
|
||||
" due to timeout",
|
||||
);
|
||||
apiCall(
|
||||
envSecrets,
|
||||
monitor.type_data.url,
|
||||
monitor.type_data.method,
|
||||
JSON.stringify(monitor.type_data.headers),
|
||||
monitor.type_data.body,
|
||||
monitor.type_data.timeout,
|
||||
monitor.type_data.eval,
|
||||
monitor.tag,
|
||||
).then(async (data) => {
|
||||
console.log("Retrying api call for " + monitor.name + " at " + startOfMinute + " due to timeout");
|
||||
serviceClient.execute().then(async (data) => {
|
||||
await db.insertMonitoringData({
|
||||
monitor_tag: monitor.tag,
|
||||
timestamp: startOfMinute,
|
||||
@@ -441,35 +99,13 @@ const Minuter = async (monitor) => {
|
||||
});
|
||||
}
|
||||
} else if (monitor.monitor_type === "PING") {
|
||||
if (!!!monitor.type_data.pingEval) {
|
||||
monitor.type_data.pingEval = defaultPingEval;
|
||||
}
|
||||
let pingResponse = await pingCall(
|
||||
monitor.type_data.hosts,
|
||||
monitor.type_data.pingEval,
|
||||
monitor.tag,
|
||||
);
|
||||
realTimeData[startOfMinute] = pingResponse;
|
||||
realTimeData[startOfMinute] = await serviceClient.execute();
|
||||
} else if (monitor.monitor_type === "TCP") {
|
||||
if (!!!monitor.type_data.tcpEval) {
|
||||
monitor.type_data.tcpEval = defaultTcpEval;
|
||||
}
|
||||
let pingResponse = await tcpCall(
|
||||
monitor.type_data.hosts,
|
||||
monitor.type_data.tcpEval,
|
||||
monitor.tag,
|
||||
);
|
||||
realTimeData[startOfMinute] = pingResponse;
|
||||
realTimeData[startOfMinute] = await serviceClient.execute();
|
||||
} else if (monitor.monitor_type === "DNS") {
|
||||
const dnsResolver = new DNSResolver(monitor.type_data.nameServer);
|
||||
let dnsResponse = await dsnChecker(
|
||||
dnsResolver,
|
||||
monitor.type_data.host,
|
||||
monitor.type_data.lookupRecord,
|
||||
monitor.type_data.matchType,
|
||||
monitor.type_data.values,
|
||||
);
|
||||
realTimeData[startOfMinute] = dnsResponse;
|
||||
realTimeData[startOfMinute] = await serviceClient.execute();
|
||||
} else if (monitor.monitor_type === "GROUP") {
|
||||
realTimeData[startOfMinute] = await serviceClient.execute(startOfMinute);
|
||||
}
|
||||
|
||||
manualData = await manualIncident(monitor);
|
||||
|
||||
@@ -1,92 +1,92 @@
|
||||
const seedSiteData = {
|
||||
title: "Kener - Open-Source and Modern looking Node.js Status Page for Effortless Incident Management",
|
||||
siteName: "Kener.ing",
|
||||
siteURL: "https://kener.ing",
|
||||
home: "/",
|
||||
logo: "https://kener.ing/logo.png",
|
||||
favicon: "https://kener.ing/logo96.png",
|
||||
metaTags: [
|
||||
{
|
||||
key: "description",
|
||||
value: "Kener: Open-source modern looking Node.js status page tool, designed to make service monitoring and incident handling a breeze. It offers a sleek and user-friendly interface that simplifies tracking service outages and improves how we communicate during incidents. And the best part? Kener integrates seamlessly with GitHub, making incident management a team effort—making it easier for us to track and fix issues together in a collaborative and friendly environment."
|
||||
},
|
||||
{
|
||||
key: "og:description",
|
||||
value: "Kener: Open-source Node.js status page tool, designed to make service monitoring and incident handling a breeze. It offers a sleek and user-friendly interface that simplifies tracking service outages and improves how we communicate during incidents. And the best part? Kener integrates seamlessly with GitHub, making incident management a team effort—making it easier for us to track and fix issues together in a collaborative and friendly environment."
|
||||
},
|
||||
{ key: "og:image", value: "https://kener.ing/ss.png" },
|
||||
{
|
||||
key: "og:title",
|
||||
value: "Kener - Open-Source and Modern looking Node.js Status Page for Effortless Incident Management"
|
||||
},
|
||||
{ key: "og:type", value: "website" },
|
||||
{ key: "og:site_name", value: "Kener" },
|
||||
{ key: "twitter:card", value: "summary_large_image" },
|
||||
{ key: "twitter:site", value: "@_rajnandan_" },
|
||||
{ key: "twitter:creator", value: "@_rajnandan_" },
|
||||
{ key: "twitter:image", value: "https://kener.ing/ss.png" },
|
||||
{
|
||||
key: "twitter:title",
|
||||
value: "Kener: Open-Source and Modern looking Node.js Status Page for Effortless Incident Management"
|
||||
},
|
||||
{
|
||||
key: "twitter:description",
|
||||
value: "Kener: Open-source Node.js status page tool, designed to make service monitoring and incident handling a breeze. It offers a sleek and user-friendly interface that simplifies tracking service outages and improves how we communicate during incidents. And the best part? Kener integrates seamlessly with GitHub, making incident management a team effort—making it easier for us to track and fix issues together in a collaborative and friendly environment."
|
||||
}
|
||||
],
|
||||
nav: [
|
||||
{ name: "Documentation", url: "https://kener.ing/docs/home", iconURL: "" },
|
||||
{ name: "Github", iconURL: "", url: "https://github.com/rajnandan1/kener" },
|
||||
{ name: "Login", iconURL: "", url: "/manage/signin" }
|
||||
],
|
||||
hero: {
|
||||
title: "Kener is a Modern Open-Source Status Page System",
|
||||
subtitle: "Let your users know what's going on."
|
||||
},
|
||||
footerHTML:
|
||||
'<p class="text-center">\nMade using \n<a href="https://github.com/rajnandan1/kener" target="_blank" rel="noreferrer" class="font-medium underline underline-offset-4">\n Kener\n</a>\nan open source status page system built with Svelte and TailwindCSS.<br/>\nCreated with ❤️ by <a href="https://rajnandan.com" target="_blank" rel="noreferrer" class="font-medium underline underline-offset-4">Raj Nandan Sharma</a>.\n</p>\n',
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: [{ code: "en", name: "English", selected: true, disabled: false }]
|
||||
},
|
||||
pattern: "none",
|
||||
analytics: [
|
||||
{
|
||||
id: "G-Q3MLRXCBFT",
|
||||
type: "GA",
|
||||
name: "Google Analytics",
|
||||
script: "https://unpkg.com/@analytics/google-analytics@1.0.7/dist/@analytics/google-analytics.min.js"
|
||||
},
|
||||
{
|
||||
id: "",
|
||||
type: "AMPLITUDE",
|
||||
name: "Amplitude",
|
||||
script: "https://unpkg.com/@analytics/amplitude@0.1.3/dist/@analytics/amplitude.min.js"
|
||||
},
|
||||
{
|
||||
id: "",
|
||||
type: "MIXPANEL",
|
||||
name: "MixPanel",
|
||||
script: "https://unpkg.com/@analytics/mixpanel@0.4.0/dist/@analytics/mixpanel.min.js"
|
||||
}
|
||||
],
|
||||
theme: "none",
|
||||
themeToggle: "YES",
|
||||
barStyle: "PARTIAL",
|
||||
barRoundness: "SHARP",
|
||||
summaryStyle: "CURRENT",
|
||||
colors: {
|
||||
UP: "#67ab95",
|
||||
DOWN: "#ca3038",
|
||||
DEGRADED: "#e6ca61"
|
||||
},
|
||||
font: {
|
||||
cssSrc: "https://fonts.bunny.net/css?family=albert-sans:100,100i,200,200i,300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i&display=swap",
|
||||
family: "Albert Sans"
|
||||
},
|
||||
categories: [{ name: "Home", description: "Monitors for Home Page" }],
|
||||
homeIncidentCount: 5,
|
||||
homeIncidentStartTimeWithin: 30
|
||||
title: "Kener - Open source status page system",
|
||||
siteName: "Kener.ing",
|
||||
siteURL: "https://kener.ing",
|
||||
home: "/",
|
||||
logo: "https://kener.ing/logo.png",
|
||||
favicon: "https://kener.ing/logo96.png",
|
||||
metaTags: [
|
||||
{
|
||||
key: "description",
|
||||
value: "Add your description here",
|
||||
},
|
||||
{
|
||||
key: "og:description",
|
||||
value: "Add your description here",
|
||||
},
|
||||
{ key: "og:image", value: "https://kener.ing/newbg.png" },
|
||||
{
|
||||
key: "og:title",
|
||||
value: "Kener - Open source status page system",
|
||||
},
|
||||
{ key: "og:type", value: "website" },
|
||||
{ key: "og:site_name", value: "Kener" },
|
||||
{ key: "twitter:card", value: "summary_large_image" },
|
||||
{ key: "twitter:site", value: "@_rajnandan_" },
|
||||
{ key: "twitter:creator", value: "@_rajnandan_" },
|
||||
{ key: "twitter:image", value: "https://kener.ing/newbg.png" },
|
||||
{
|
||||
key: "twitter:title",
|
||||
value: "Kener - Open source status page system",
|
||||
},
|
||||
{
|
||||
key: "twitter:description",
|
||||
value: "Add your description here",
|
||||
},
|
||||
],
|
||||
nav: [
|
||||
{ name: "Documentation", url: "https://kener.ing/docs/home", iconURL: "" },
|
||||
{ name: "Github", iconURL: "", url: "https://github.com/rajnandan1/kener" },
|
||||
{ name: "Setup Kener", iconURL: "", url: "/manage/signin" },
|
||||
],
|
||||
hero: {
|
||||
title: "Build stunning status pages in minutes",
|
||||
subtitle: "Let your users know what's going on.",
|
||||
},
|
||||
footerHTML:
|
||||
'<p class="text-center">\nMade using \n<a href="https://github.com/rajnandan1/kener" target="_blank" rel="noreferrer" class="font-medium underline underline-offset-4">\n Kener\n</a>\nan open source status page system built with Svelte and TailwindCSS.<br/>\nCreated with ❤️ by <a href="https://rajnandan.com" target="_blank" rel="noreferrer" class="font-medium underline underline-offset-4">Raj Nandan Sharma</a>.\n</p>\n',
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: [{ code: "en", name: "English", selected: true, disabled: false }],
|
||||
},
|
||||
pattern: "none",
|
||||
analytics: [
|
||||
{
|
||||
id: "",
|
||||
type: "GA",
|
||||
name: "Google Analytics",
|
||||
script: "https://unpkg.com/@analytics/google-analytics@1.0.7/dist/@analytics/google-analytics.min.js",
|
||||
},
|
||||
{
|
||||
id: "",
|
||||
type: "AMPLITUDE",
|
||||
name: "Amplitude",
|
||||
script: "https://unpkg.com/@analytics/amplitude@0.1.3/dist/@analytics/amplitude.min.js",
|
||||
},
|
||||
{
|
||||
id: "",
|
||||
type: "MIXPANEL",
|
||||
name: "MixPanel",
|
||||
script: "https://unpkg.com/@analytics/mixpanel@0.4.0/dist/@analytics/mixpanel.min.js",
|
||||
},
|
||||
],
|
||||
theme: "none",
|
||||
themeToggle: "YES",
|
||||
barStyle: "PARTIAL",
|
||||
barRoundness: "SHARP",
|
||||
summaryStyle: "CURRENT",
|
||||
colors: {
|
||||
UP: "#67ab95",
|
||||
DOWN: "#ca3038",
|
||||
DEGRADED: "#e6ca61",
|
||||
},
|
||||
font: {
|
||||
cssSrc: "",
|
||||
family: "",
|
||||
},
|
||||
categories: [{ name: "Home", description: "Monitors for Home Page" }],
|
||||
homeIncidentCount: 5,
|
||||
homeIncidentStartTimeWithin: 30,
|
||||
};
|
||||
|
||||
export default seedSiteData;
|
||||
|
||||
@@ -3,24 +3,33 @@ import nodemailer from "nodemailer";
|
||||
import { HashString } from "../controllers/controller.js";
|
||||
const transports = {};
|
||||
export default function getSMTPTransport(meta) {
|
||||
//convert meta to string and generate has id
|
||||
//convert meta to string and generate has id
|
||||
|
||||
let transportId = "smtp_" + HashString(JSON.stringify(meta));
|
||||
let transportId = "smtp_" + HashString(JSON.stringify(meta));
|
||||
|
||||
if (!!transports[transportId]) {
|
||||
return transports[transportId];
|
||||
}
|
||||
if (!!transports[transportId]) {
|
||||
return transports[transportId];
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: meta.smtp_host,
|
||||
port: Number(meta.smtp_port) || 587,
|
||||
secure: meta.smtp_secure, // true for 465, false for other ports
|
||||
auth: {
|
||||
user: meta.smtp_user,
|
||||
pass: meta.smtp_pass
|
||||
}
|
||||
});
|
||||
transports[transportId] = transporter;
|
||||
let options = {
|
||||
host: meta.smtp_host,
|
||||
port: Number(meta.smtp_port) || 587,
|
||||
secure: meta.smtp_secure, // true for 465, false for other ports
|
||||
auth: {
|
||||
user: meta.smtp_user,
|
||||
pass: meta.smtp_pass,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: !!meta.smtp_secure, // false if smtp_secure is 0
|
||||
},
|
||||
};
|
||||
|
||||
return transporter;
|
||||
if (meta.smtp_user === "-" || meta.smtp_pass === "-") {
|
||||
delete options.auth;
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport(options);
|
||||
transports[transportId] = transporter;
|
||||
|
||||
return transporter;
|
||||
}
|
||||
|
||||
@@ -2,211 +2,196 @@
|
||||
// @ts-ignore
|
||||
import db from "$lib/server/db/db.js";
|
||||
import {
|
||||
GetMinuteStartNowTimestampUTC,
|
||||
BeginningOfDay,
|
||||
StatusObj,
|
||||
ParseUptime,
|
||||
GetDayStartTimestampUTC
|
||||
GetMinuteStartNowTimestampUTC,
|
||||
BeginningOfDay,
|
||||
StatusObj,
|
||||
ParseUptime,
|
||||
GetDayStartTimestampUTC,
|
||||
} from "$lib/server/tool.js";
|
||||
import { formatDuration, intervalToDuration } from "date-fns";
|
||||
import { fdm, l, summaryTime } from "$lib/i18n/client";
|
||||
import {
|
||||
GetDataGroupByDayAlternative,
|
||||
InterpolateData,
|
||||
GetLastStatusBefore
|
||||
GetDataGroupByDayAlternative,
|
||||
InterpolateData,
|
||||
GetLastStatusBefore,
|
||||
} from "$lib/server/controllers/controller.js";
|
||||
import { UP, DOWN, DEGRADED, NO_DATA } from "$lib/server/constants.js";
|
||||
|
||||
function getSummaryDuration(numOfMinute, selectedLang) {
|
||||
// Convert minutes to milliseconds and create duration object
|
||||
const duration = intervalToDuration({
|
||||
start: new Date(0),
|
||||
end: new Date(numOfMinute * 60 * 1000)
|
||||
});
|
||||
// Convert minutes to milliseconds and create duration object
|
||||
const duration = intervalToDuration({
|
||||
start: new Date(0),
|
||||
end: new Date(numOfMinute * 60 * 1000),
|
||||
});
|
||||
|
||||
return fdm(duration, selectedLang);
|
||||
return fdm(duration, selectedLang);
|
||||
}
|
||||
function getTimezoneOffset(timeZone) {
|
||||
const formatter = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "short" });
|
||||
const parts = formatter.formatToParts(new Date());
|
||||
const timeZoneOffset = parts.find((part) => part.type === "timeZoneName")?.value;
|
||||
const formatter = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "short" });
|
||||
const parts = formatter.formatToParts(new Date());
|
||||
const timeZoneOffset = parts.find((part) => part.type === "timeZoneName")?.value;
|
||||
|
||||
const match = timeZoneOffset.match(/([+-]\d{2}):?(\d{2})/);
|
||||
if (!match) return 0;
|
||||
const match = timeZoneOffset.match(/([+-]\d{2}):?(\d{2})/);
|
||||
if (!match) return 0;
|
||||
|
||||
const [, hours, minutes] = match;
|
||||
return parseInt(hours) * 60 + parseInt(minutes);
|
||||
const [, hours, minutes] = match;
|
||||
return parseInt(hours) * 60 + parseInt(minutes);
|
||||
}
|
||||
|
||||
function returnStatusClass(val, total, c, barStyle) {
|
||||
// return "api-degraded-10";
|
||||
if (barStyle === undefined || barStyle == "FULL") {
|
||||
return c;
|
||||
} else if (barStyle == "PARTIAL") {
|
||||
let totalHeight = total;
|
||||
let cl = `api-up`;
|
||||
if (val > 0 && val <= 0.1 * totalHeight) {
|
||||
cl = c + "-10";
|
||||
} else if (val > 0.1 * totalHeight && val <= 0.2 * totalHeight) {
|
||||
cl = c + "-20";
|
||||
} else if (val > 0.2 * totalHeight && val <= 0.4 * totalHeight) {
|
||||
cl = c + "-40";
|
||||
} else if (val > 0.4 * totalHeight && val <= 0.6 * totalHeight) {
|
||||
cl = c + "-60";
|
||||
} else if (val > 0.6 * totalHeight && val <= 0.8 * totalHeight) {
|
||||
cl = c + "-80";
|
||||
} else if (val > 0.8 * totalHeight && val < totalHeight) {
|
||||
cl = c + "-90";
|
||||
} else if (val == totalHeight) {
|
||||
cl = c;
|
||||
}
|
||||
return cl;
|
||||
}
|
||||
return c;
|
||||
// return "api-degraded-10";
|
||||
if (barStyle === undefined || barStyle == "FULL") {
|
||||
return c;
|
||||
} else if (barStyle == "PARTIAL") {
|
||||
let totalHeight = total;
|
||||
let cl = `api-up`;
|
||||
if (val > 0 && val <= 0.1 * totalHeight) {
|
||||
cl = c + "-10";
|
||||
} else if (val > 0.1 * totalHeight && val <= 0.2 * totalHeight) {
|
||||
cl = c + "-20";
|
||||
} else if (val > 0.2 * totalHeight && val <= 0.4 * totalHeight) {
|
||||
cl = c + "-40";
|
||||
} else if (val > 0.4 * totalHeight && val <= 0.6 * totalHeight) {
|
||||
cl = c + "-60";
|
||||
} else if (val > 0.6 * totalHeight && val <= 0.8 * totalHeight) {
|
||||
cl = c + "-80";
|
||||
} else if (val > 0.8 * totalHeight && val < totalHeight) {
|
||||
cl = c + "-90";
|
||||
} else if (val == totalHeight) {
|
||||
cl = c;
|
||||
}
|
||||
return cl;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
function getCountOfSimilarStatuesEnd(arr, statusType) {
|
||||
let count = 0;
|
||||
for (let i = arr.length - 1; i >= 0; i--) {
|
||||
if (arr[i].status === statusType) {
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
let count = 0;
|
||||
for (let i = arr.length - 1; i >= 0; i--) {
|
||||
if (arr[i].status === statusType) {
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
const FetchData = async function (site, monitor, localTz, selectedLang, lang) {
|
||||
const secondsInDay = 24 * 60 * 60;
|
||||
//get offset from utc in minutes
|
||||
const now = GetMinuteStartNowTimestampUTC() + 60;
|
||||
const midnight = BeginningOfDay({ timeZone: localTz });
|
||||
const midnight90DaysAgo = midnight - 90 * 24 * 60 * 60;
|
||||
const NO_DATA = "No Data";
|
||||
const midnightTomorrow = midnight + secondsInDay;
|
||||
let offsetInMinutes = parseInt((GetDayStartTimestampUTC(now) - midnight) / 60);
|
||||
const _90Day = {};
|
||||
let latestTimestamp = 0;
|
||||
const secondsInDay = 24 * 60 * 60;
|
||||
//get offset from utc in minutes
|
||||
const now = GetMinuteStartNowTimestampUTC() + 60;
|
||||
const midnight = BeginningOfDay({ timeZone: localTz });
|
||||
const midnight90DaysAgo = midnight - 90 * 24 * 60 * 60;
|
||||
const NO_DATA = "No Data";
|
||||
const midnightTomorrow = midnight + secondsInDay;
|
||||
let offsetInMinutes = parseInt((GetDayStartTimestampUTC(now) - midnight) / 60);
|
||||
const _90Day = {};
|
||||
let latestTimestamp = 0;
|
||||
|
||||
let dbData = await GetDataGroupByDayAlternative(
|
||||
monitor.tag,
|
||||
midnight90DaysAgo,
|
||||
midnightTomorrow,
|
||||
offsetInMinutes
|
||||
);
|
||||
let totalDegradedCount = 0;
|
||||
let totalDownCount = 0;
|
||||
let totalUpCount = 0;
|
||||
let dbData = await GetDataGroupByDayAlternative(monitor.tag, midnight90DaysAgo, midnightTomorrow, offsetInMinutes);
|
||||
let totalDegradedCount = 0;
|
||||
let totalDownCount = 0;
|
||||
let totalUpCount = 0;
|
||||
|
||||
for (let i = 0; i < dbData.length; i++) {
|
||||
let dayData = dbData[i];
|
||||
let ts = dayData.timestamp;
|
||||
let cssClass = StatusObj.UP;
|
||||
let summaryDuration = 0;
|
||||
let summaryStatus = "UP";
|
||||
latestTimestamp = ts;
|
||||
totalDegradedCount += dayData.DEGRADED;
|
||||
totalDownCount += dayData.DOWN;
|
||||
totalUpCount += dayData.UP;
|
||||
for (let i = 0; i < dbData.length; i++) {
|
||||
let dayData = dbData[i];
|
||||
let ts = dayData.timestamp;
|
||||
let cssClass = StatusObj.UP;
|
||||
let summaryDuration = 0;
|
||||
let summaryStatus = UP;
|
||||
latestTimestamp = ts;
|
||||
totalDegradedCount += dayData.DEGRADED;
|
||||
totalDownCount += dayData.DOWN;
|
||||
totalUpCount += dayData.UP;
|
||||
|
||||
if (dayData.DEGRADED >= monitor.day_degraded_minimum_count) {
|
||||
cssClass = returnStatusClass(
|
||||
dayData.DEGRADED,
|
||||
dayData.total,
|
||||
StatusObj.DEGRADED,
|
||||
site.barStyle
|
||||
);
|
||||
summaryDuration = getSummaryDuration(dayData.DEGRADED, selectedLang);
|
||||
summaryStatus = "DEGRADED";
|
||||
}
|
||||
if (dayData.DOWN >= monitor.day_down_minimum_count) {
|
||||
cssClass = returnStatusClass(
|
||||
dayData.DOWN,
|
||||
dayData.total,
|
||||
StatusObj.DOWN,
|
||||
site.barStyle
|
||||
);
|
||||
if (dayData.DEGRADED >= monitor.day_degraded_minimum_count) {
|
||||
cssClass = returnStatusClass(dayData.DEGRADED, dayData.total, StatusObj.DEGRADED, site.barStyle);
|
||||
summaryDuration = getSummaryDuration(dayData.DEGRADED, selectedLang);
|
||||
summaryStatus = "DEGRADED";
|
||||
}
|
||||
if (dayData.DOWN >= monitor.day_down_minimum_count) {
|
||||
cssClass = returnStatusClass(dayData.DOWN, dayData.total, StatusObj.DOWN, site.barStyle);
|
||||
|
||||
summaryDuration = getSummaryDuration(dayData.DOWN, selectedLang);
|
||||
summaryStatus = "DOWN";
|
||||
}
|
||||
summaryDuration = getSummaryDuration(dayData.DOWN, selectedLang);
|
||||
summaryStatus = "DOWN";
|
||||
}
|
||||
if (dayData.NO_DATA === dayData.total) {
|
||||
cssClass = StatusObj.NO_DATA;
|
||||
summaryStatus = NO_DATA;
|
||||
}
|
||||
|
||||
_90Day[ts] = {
|
||||
border: true
|
||||
};
|
||||
_90Day[ts] = {
|
||||
border: true,
|
||||
};
|
||||
|
||||
_90Day[ts].timestamp = ts;
|
||||
_90Day[ts].cssClass = cssClass;
|
||||
_90Day[ts].summaryStatus = l(lang, summaryTime(summaryStatus), {
|
||||
status: l(lang, summaryStatus),
|
||||
duration: summaryDuration
|
||||
});
|
||||
_90Day[ts].timestamp = ts;
|
||||
_90Day[ts].cssClass = cssClass;
|
||||
_90Day[ts].summaryStatus = l(lang, summaryTime(summaryStatus), {
|
||||
status: l(lang, summaryStatus),
|
||||
duration: summaryDuration,
|
||||
});
|
||||
|
||||
_90Day[ts].textClass = cssClass.replace(/-\d+$/, "");
|
||||
}
|
||||
let uptime90DayNumerator = totalUpCount + totalDegradedCount;
|
||||
let uptime90DayDenominator = totalUpCount + totalDownCount + totalDegradedCount;
|
||||
_90Day[ts].textClass = cssClass.replace(/-\d+$/, "");
|
||||
}
|
||||
|
||||
//remove degraded from uptime
|
||||
if (monitor.include_degraded_in_downtime === "YES") {
|
||||
uptime90DayNumerator = totalUpCount;
|
||||
}
|
||||
// return _90Day;
|
||||
let uptime90Day = ParseUptime(uptime90DayNumerator, uptime90DayDenominator);
|
||||
let uptime90DayNumerator = totalUpCount + totalDegradedCount;
|
||||
let uptime90DayDenominator = totalUpCount + totalDownCount + totalDegradedCount;
|
||||
|
||||
let summaryDuration = 0;
|
||||
let summaryStatus = "UP";
|
||||
//remove degraded from uptime
|
||||
if (monitor.include_degraded_in_downtime === "YES") {
|
||||
uptime90DayNumerator = totalUpCount;
|
||||
}
|
||||
// return _90Day;
|
||||
let uptime90Day = ParseUptime(uptime90DayNumerator, uptime90DayDenominator);
|
||||
|
||||
let summaryColorClass = "api-nodata";
|
||||
let summaryDuration = 0;
|
||||
let summaryStatus = "UP";
|
||||
|
||||
let todayDataDb = await db.getMonitoringData(monitor.tag, midnight, midnight + secondsInDay);
|
||||
let anchorStatus = await GetLastStatusBefore(monitor.tag, midnight);
|
||||
todayDataDb = InterpolateData(
|
||||
todayDataDb,
|
||||
midnight,
|
||||
anchorStatus,
|
||||
todayDataDb.length == 0 ? midnight + secondsInDay : null
|
||||
);
|
||||
let summaryColorClass = "api-nodata";
|
||||
|
||||
if (site.summaryStyle === "CURRENT") {
|
||||
summaryColorClass = "api-up";
|
||||
let todayDataDb = await db.getMonitoringData(monitor.tag, midnight, midnight + secondsInDay);
|
||||
let anchorStatus = await GetLastStatusBefore(monitor.tag, midnight);
|
||||
todayDataDb = InterpolateData(
|
||||
todayDataDb,
|
||||
midnight,
|
||||
anchorStatus,
|
||||
todayDataDb.length == 0 ? midnight + secondsInDay : null,
|
||||
);
|
||||
|
||||
let lastRow = todayDataDb[todayDataDb.length - 1];
|
||||
if (site.summaryStyle === "CURRENT") {
|
||||
summaryColorClass = "api-up";
|
||||
|
||||
if (!!lastRow && lastRow.status == "DEGRADED") {
|
||||
summaryDuration = getSummaryDuration(
|
||||
getCountOfSimilarStatuesEnd(todayDataDb, "DEGRADED"),
|
||||
selectedLang
|
||||
);
|
||||
summaryStatus = "DEGRADED";
|
||||
summaryColorClass = "api-degraded";
|
||||
}
|
||||
if (!!lastRow && lastRow.status == "DOWN") {
|
||||
summaryDuration = getSummaryDuration(
|
||||
getCountOfSimilarStatuesEnd(todayDataDb, "DOWN"),
|
||||
selectedLang
|
||||
);
|
||||
summaryStatus = "DOWN";
|
||||
summaryColorClass = "api-down";
|
||||
}
|
||||
summaryStatus = l(lang, summaryTime(summaryStatus), {
|
||||
status: l(lang, summaryStatus),
|
||||
duration: summaryDuration
|
||||
});
|
||||
} else {
|
||||
let lastData = _90Day[latestTimestamp];
|
||||
summaryColorClass = lastData.cssClass.replace(/-\d+$/, "");
|
||||
summaryDuration = lastData.summaryDuration;
|
||||
summaryStatus = lastData.summaryStatus;
|
||||
}
|
||||
return {
|
||||
_90Day: _90Day,
|
||||
uptime90Day: uptime90Day,
|
||||
summaryStatus: summaryStatus,
|
||||
summaryColorClass: summaryColorClass,
|
||||
barRoundness: site.barRoundness,
|
||||
midnight90DaysAgo: midnight90DaysAgo,
|
||||
midnightTomorrow: midnightTomorrow
|
||||
};
|
||||
let lastRow = todayDataDb[todayDataDb.length - 1];
|
||||
|
||||
if (!!lastRow && lastRow.status == "DEGRADED") {
|
||||
summaryDuration = getSummaryDuration(getCountOfSimilarStatuesEnd(todayDataDb, "DEGRADED"), selectedLang);
|
||||
summaryStatus = "DEGRADED";
|
||||
summaryColorClass = "api-degraded";
|
||||
}
|
||||
if (!!lastRow && lastRow.status == "DOWN") {
|
||||
summaryDuration = getSummaryDuration(getCountOfSimilarStatuesEnd(todayDataDb, "DOWN"), selectedLang);
|
||||
summaryStatus = "DOWN";
|
||||
summaryColorClass = "api-down";
|
||||
}
|
||||
summaryStatus = l(lang, summaryTime(summaryStatus), {
|
||||
status: l(lang, summaryStatus),
|
||||
duration: summaryDuration,
|
||||
});
|
||||
} else {
|
||||
let lastData = _90Day[latestTimestamp];
|
||||
summaryColorClass = lastData.cssClass.replace(/-\d+$/, "");
|
||||
summaryDuration = lastData.summaryDuration;
|
||||
summaryStatus = lastData.summaryStatus;
|
||||
}
|
||||
return {
|
||||
_90Day: _90Day,
|
||||
uptime90Day: uptime90Day,
|
||||
summaryStatus: summaryStatus,
|
||||
summaryColorClass: summaryColorClass,
|
||||
barRoundness: site.barRoundness,
|
||||
midnight90DaysAgo: midnight90DaysAgo,
|
||||
midnightTomorrow: midnightTomorrow,
|
||||
};
|
||||
};
|
||||
export { FetchData };
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// @ts-nocheck
|
||||
import axios from "axios";
|
||||
import { GetRequiredSecrets, ReplaceAllOccurrences } from "../tool.js";
|
||||
import { UP, DOWN, DEGRADED, REALTIME, TIMEOUT, ERROR, MANUAL } from "../constants.js";
|
||||
|
||||
const defaultEval = `(async function (statusCode, responseTime, responseData) {
|
||||
let statusCodeShort = Math.floor(statusCode/100);
|
||||
if(statusCode == 429 || (statusCodeShort >=2 && statusCodeShort <= 3)) {
|
||||
return {
|
||||
status: 'UP',
|
||||
latency: responseTime,
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'DOWN',
|
||||
latency: responseTime,
|
||||
}
|
||||
})`;
|
||||
|
||||
class ApiCall {
|
||||
monitor;
|
||||
envSecrets;
|
||||
|
||||
constructor(monitor) {
|
||||
this.monitor = monitor;
|
||||
this.envSecrets = GetRequiredSecrets(
|
||||
`${monitor.type_data.url} ${monitor.type_data.body} ${JSON.stringify(monitor.type_data.headers)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async execute() {
|
||||
let axiosHeaders = {};
|
||||
axiosHeaders["User-Agent"] = "Kener/" + "3.1.0";
|
||||
axiosHeaders["Accept"] = "*/*";
|
||||
|
||||
let body = this.monitor.type_data.body;
|
||||
let url = this.monitor.type_data.url;
|
||||
|
||||
//headers to string
|
||||
let headers = "";
|
||||
if (!!this.monitor.type_data.headers) {
|
||||
headers = JSON.stringify(this.monitor.type_data.headers);
|
||||
}
|
||||
|
||||
let method = this.monitor.type_data.method;
|
||||
let timeout = this.monitor.type_data.timeout || 5000;
|
||||
let tag = this.monitor.tag;
|
||||
let monitorEval = !!this.monitor.type_data.monitorEval ? this.monitor.type_data.monitorEval : defaultEval;
|
||||
|
||||
for (let i = 0; i < this.envSecrets.length; i++) {
|
||||
const secret = this.envSecrets[i];
|
||||
if (!!body) {
|
||||
body = ReplaceAllOccurrences(body, secret.find, secret.replace);
|
||||
}
|
||||
if (!!url) {
|
||||
url = ReplaceAllOccurrences(url, secret.find, secret.replace);
|
||||
}
|
||||
if (!!headers) {
|
||||
headers = ReplaceAllOccurrences(headers, secret.find, secret.replace);
|
||||
}
|
||||
}
|
||||
|
||||
if (!!headers) {
|
||||
try {
|
||||
headers = JSON.parse(headers);
|
||||
headers = headers.reduce((acc, header) => {
|
||||
acc[header.key] = header.value;
|
||||
return acc;
|
||||
}, {});
|
||||
axiosHeaders = { ...axiosHeaders, ...headers };
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
|
||||
const options = {
|
||||
method: method,
|
||||
headers: axiosHeaders,
|
||||
timeout: timeout,
|
||||
transformResponse: (r) => r,
|
||||
};
|
||||
|
||||
if (!!body) {
|
||||
options.data = body;
|
||||
}
|
||||
let statusCode = 500;
|
||||
let latency = 0;
|
||||
let resp = "";
|
||||
let timeoutError = false;
|
||||
const start = Date.now();
|
||||
try {
|
||||
let data = await axios(url, options);
|
||||
statusCode = data.status;
|
||||
resp = data.data;
|
||||
} catch (err) {
|
||||
console.log(`Error in apiCall ${tag}`, err.message);
|
||||
if (err.message.startsWith("timeout of") && err.message.endsWith("exceeded")) {
|
||||
timeoutError = true;
|
||||
}
|
||||
if (err.response !== undefined && err.response.status !== undefined) {
|
||||
statusCode = err.response.status;
|
||||
}
|
||||
if (err.response !== undefined && err.response.data !== undefined) {
|
||||
resp = err.response.data;
|
||||
} else {
|
||||
resp = JSON.stringify(resp);
|
||||
}
|
||||
} finally {
|
||||
const end = Date.now();
|
||||
latency = end - start;
|
||||
if (resp === undefined || resp === null) {
|
||||
resp = "";
|
||||
}
|
||||
}
|
||||
|
||||
resp = Buffer.from(resp).toString("base64");
|
||||
|
||||
let evalResp = undefined;
|
||||
|
||||
try {
|
||||
evalResp = await eval(monitorEval + `(${statusCode}, ${latency}, "${resp}")`);
|
||||
} catch (error) {
|
||||
console.log(`Error in monitorEval for ${tag}`, error.message);
|
||||
}
|
||||
|
||||
if (evalResp === undefined || evalResp === null) {
|
||||
evalResp = {
|
||||
status: DOWN,
|
||||
latency: latency,
|
||||
type: ERROR,
|
||||
};
|
||||
} else if (
|
||||
evalResp.status === undefined ||
|
||||
evalResp.status === null ||
|
||||
[UP, DOWN, DEGRADED].indexOf(evalResp.status) === -1
|
||||
) {
|
||||
evalResp = {
|
||||
status: DOWN,
|
||||
latency: latency,
|
||||
type: ERROR,
|
||||
};
|
||||
} else {
|
||||
evalResp.type = REALTIME;
|
||||
}
|
||||
|
||||
let toWrite = {
|
||||
status: DOWN,
|
||||
latency: latency,
|
||||
type: ERROR,
|
||||
};
|
||||
if (evalResp.status !== undefined && evalResp.status !== null) {
|
||||
toWrite.status = evalResp.status;
|
||||
}
|
||||
if (evalResp.latency !== undefined && evalResp.latency !== null) {
|
||||
toWrite.latency = evalResp.latency;
|
||||
}
|
||||
if (evalResp.type !== undefined && evalResp.type !== null) {
|
||||
toWrite.type = evalResp.type;
|
||||
}
|
||||
if (timeoutError) {
|
||||
toWrite.type = TIMEOUT;
|
||||
}
|
||||
|
||||
return toWrite;
|
||||
}
|
||||
}
|
||||
|
||||
export default ApiCall;
|
||||
@@ -0,0 +1,76 @@
|
||||
// @ts-nocheck
|
||||
import axios from "axios";
|
||||
import DNSResolver from "../dns.js";
|
||||
import { UP, DOWN, DEGRADED, REALTIME, TIMEOUT, ERROR, MANUAL } from "../constants.js";
|
||||
|
||||
class DnsCall {
|
||||
monitor;
|
||||
|
||||
constructor(monitor) {
|
||||
this.monitor = monitor;
|
||||
}
|
||||
|
||||
async execute() {
|
||||
const dnsResolver = new DNSResolver(this.monitor.type_data.nameServer);
|
||||
let host = this.monitor.type_data.host;
|
||||
let recordType = this.monitor.type_data.lookupRecord;
|
||||
let matchType = this.monitor.type_data.matchType;
|
||||
let values = this.monitor.type_data.values;
|
||||
|
||||
try {
|
||||
let queryStartTime = Date.now();
|
||||
let dnsRes = await dnsResolver.getRecord(host, recordType);
|
||||
let latency = Date.now() - queryStartTime;
|
||||
|
||||
if (dnsRes[recordType] === undefined) {
|
||||
return {
|
||||
status: DOWN,
|
||||
latency: latency,
|
||||
type: REALTIME,
|
||||
};
|
||||
}
|
||||
let data = dnsRes[recordType];
|
||||
let dnsData = data.map((d) => d.data);
|
||||
if (matchType === "ALL") {
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
if (dnsData.indexOf(values[i].trim()) === -1) {
|
||||
return {
|
||||
status: DOWN,
|
||||
latency: latency,
|
||||
type: REALTIME,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: UP,
|
||||
latency: latency,
|
||||
type: REALTIME,
|
||||
};
|
||||
} else if (matchType === "ANY") {
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
if (dnsData.indexOf(values[i].trim()) !== -1) {
|
||||
return {
|
||||
status: UP,
|
||||
latency: latency,
|
||||
type: REALTIME,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: DOWN,
|
||||
latency: latency,
|
||||
type: REALTIME,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error in dnsChecker", error);
|
||||
return {
|
||||
status: DOWN,
|
||||
latency: 0,
|
||||
type: REALTIME,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default DnsCall;
|
||||
@@ -0,0 +1,37 @@
|
||||
// @ts-nocheck
|
||||
import axios from "axios";
|
||||
import { GetRequiredSecrets, ReplaceAllOccurrences, Wait } from "../tool.js";
|
||||
import { UP, DOWN, DEGRADED, REALTIME, TIMEOUT, ERROR, MANUAL } from "../constants.js";
|
||||
import db from "../db/db.js";
|
||||
|
||||
async function waitForDataAndReturn(arr, ts, d, maxTime) {
|
||||
await Wait(d);
|
||||
let data = await db.getLastStatusBeforeCombined(arr, ts);
|
||||
if (data) {
|
||||
data.type = REALTIME;
|
||||
return data;
|
||||
} else if (d > maxTime) {
|
||||
return {
|
||||
status: DOWN,
|
||||
latency: 0,
|
||||
type: TIMEOUT,
|
||||
};
|
||||
} else {
|
||||
return await waitForDataAndReturn(arr, ts, d * 2, maxTime);
|
||||
}
|
||||
}
|
||||
|
||||
class GroupCall {
|
||||
monitor;
|
||||
|
||||
constructor(monitor, timestamp) {
|
||||
this.monitor = monitor;
|
||||
}
|
||||
|
||||
async execute(startOfMinute) {
|
||||
let tagArr = this.monitor.type_data.monitors.map((m) => m.tag);
|
||||
return await waitForDataAndReturn(tagArr, startOfMinute, 500, this.monitor.type_data.timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export default GroupCall;
|
||||
@@ -0,0 +1,81 @@
|
||||
// @ts-nocheck
|
||||
import axios from "axios";
|
||||
import { Ping } from "../ping.js";
|
||||
import {
|
||||
UP,
|
||||
DOWN,
|
||||
DEGRADED,
|
||||
REALTIME,
|
||||
TIMEOUT,
|
||||
ERROR,
|
||||
MANUAL,
|
||||
} from "../constants.js";
|
||||
|
||||
const defaultPingEval = `(async function (responseDataBase64) {
|
||||
let arrayOfPings = JSON.parse(atob(responseDataBase64));
|
||||
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,
|
||||
}
|
||||
})`;
|
||||
|
||||
class PingCall {
|
||||
monitor;
|
||||
|
||||
constructor(monitor) {
|
||||
this.monitor = monitor;
|
||||
}
|
||||
|
||||
async execute() {
|
||||
let hosts = this.monitor.type_data.hosts;
|
||||
let pingEval = !!this.monitor.type_data.pingEval
|
||||
? this.monitor.type_data.pingEval
|
||||
: defaultPingEval;
|
||||
let tag = this.monitor.tag;
|
||||
if (hosts === undefined) {
|
||||
console.log(
|
||||
"Hosts is undefined. The ping monitor has changed in version 3.0.10. Please update your monitor with tag",
|
||||
tag,
|
||||
);
|
||||
return {
|
||||
status: DOWN,
|
||||
latency: 0,
|
||||
type: ERROR,
|
||||
};
|
||||
}
|
||||
let arrayOfPings = [];
|
||||
for (let i = 0; i < hosts.length; i++) {
|
||||
const host = hosts[i];
|
||||
arrayOfPings.push(
|
||||
await Ping(host.type, host.host, host.timeout, host.count),
|
||||
);
|
||||
}
|
||||
let respBase64 = Buffer.from(JSON.stringify(arrayOfPings)).toString(
|
||||
"base64",
|
||||
);
|
||||
|
||||
let evalResp = undefined;
|
||||
|
||||
try {
|
||||
evalResp = await eval(pingEval + `("${respBase64}")`);
|
||||
} catch (error) {
|
||||
console.log(`Error in pingEval for ${tag}`, error.message);
|
||||
}
|
||||
//reduce to get the status
|
||||
return {
|
||||
status: evalResp.status,
|
||||
latency: evalResp.latency,
|
||||
type: REALTIME,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default PingCall;
|
||||
@@ -0,0 +1,34 @@
|
||||
// @ts-nocheck
|
||||
import ApiCall from "./apiCall.js";
|
||||
import PingCall from "./pingCall.js";
|
||||
import TcpCall from "./tcpCall.js";
|
||||
import DnsCall from "./dnsCall.js";
|
||||
import GroupCall from "./groupCall.js";
|
||||
|
||||
class Service {
|
||||
service;
|
||||
|
||||
constructor(monitor) {
|
||||
if (monitor.monitor_type === "API") {
|
||||
this.service = new ApiCall(monitor);
|
||||
} else if (monitor.monitor_type === "PING") {
|
||||
this.service = new PingCall(monitor);
|
||||
} else if (monitor.monitor_type === "TCP") {
|
||||
this.service = new TcpCall(monitor);
|
||||
} else if (monitor.monitor_type === "DNS") {
|
||||
this.service = new DnsCall(monitor);
|
||||
} else if (monitor.monitor_type === "NONE") {
|
||||
this.service = null;
|
||||
} else if (monitor.monitor_type === "GROUP") {
|
||||
this.service = new GroupCall(monitor);
|
||||
} else {
|
||||
console.log("Invalid monitor.monitor_type ", monitor.monitor_type);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
async execute(...p) {
|
||||
return await this.service.execute(...p);
|
||||
}
|
||||
}
|
||||
|
||||
export default Service;
|
||||
@@ -0,0 +1,72 @@
|
||||
// @ts-nocheck
|
||||
import axios from "axios";
|
||||
import { TCP } from "../ping.js";
|
||||
import { UP, DOWN, DEGRADED, REALTIME, TIMEOUT, ERROR, MANUAL } from "../constants.js";
|
||||
|
||||
const defaultTcpEval = `(async function (responseDataBase64) {
|
||||
let arrayOfPings = JSON.parse(atob(responseDataBase64));
|
||||
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
|
||||
return acc + ping.latency;
|
||||
}, 0);
|
||||
|
||||
let alive = arrayOfPings.reduce((acc, ping) => {
|
||||
if (ping.status === "open") {
|
||||
return acc && true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}, true);
|
||||
|
||||
return {
|
||||
status: alive ? 'UP' : 'DOWN',
|
||||
latency: latencyTotal / arrayOfPings.length,
|
||||
}
|
||||
})`;
|
||||
|
||||
class TcpCall {
|
||||
monitor;
|
||||
|
||||
constructor(monitor) {
|
||||
this.monitor = monitor;
|
||||
}
|
||||
|
||||
async execute() {
|
||||
let hosts = this.monitor.type_data.hosts;
|
||||
let tcpEval = !!this.monitor.type_data.tcpEval ? this.monitor.type_data.tcpEval : defaultTcpEval;
|
||||
let tag = this.monitor.tag;
|
||||
|
||||
if (hosts === undefined) {
|
||||
console.log(
|
||||
"Hosts is undefined. The ping monitor has changed in version 3.0.10. Please update your monitor with tag",
|
||||
tag,
|
||||
);
|
||||
return {
|
||||
status: DOWN,
|
||||
latency: 0,
|
||||
type: ERROR,
|
||||
};
|
||||
}
|
||||
let arrayOfPings = [];
|
||||
for (let i = 0; i < hosts.length; i++) {
|
||||
const host = hosts[i];
|
||||
arrayOfPings.push(await TCP(host.type, host.host, host.port, host.timeout));
|
||||
}
|
||||
let respBase64 = Buffer.from(JSON.stringify(arrayOfPings)).toString("base64");
|
||||
|
||||
let evalResp = undefined;
|
||||
|
||||
try {
|
||||
evalResp = await eval(tcpEval + `("${respBase64}")`);
|
||||
} catch (error) {
|
||||
console.log(`Error in tcpEval for ${tag}`, error.message);
|
||||
}
|
||||
//reduce to get the status
|
||||
return {
|
||||
status: evalResp.status,
|
||||
latency: evalResp.latency,
|
||||
type: REALTIME,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default TcpCall;
|
||||
@@ -14,68 +14,68 @@ const jobs = [];
|
||||
process.env.TZ = "UTC";
|
||||
|
||||
const jobSchedule = async () => {
|
||||
const activeMonitors = (await GetMonitorsParsed({ status: "ACTIVE" })).map((m) => {
|
||||
return {
|
||||
...m,
|
||||
hash: "Tag: " + m.tag + " Hash:" + HashString(JSON.stringify(m))
|
||||
};
|
||||
});
|
||||
const activeMonitors = (await GetMonitorsParsed({ status: "ACTIVE" })).map((m) => {
|
||||
return {
|
||||
...m,
|
||||
hash: "Tag: " + m.tag + " Hash:" + HashString(JSON.stringify(m)),
|
||||
};
|
||||
});
|
||||
|
||||
//stop and remove jobs not present in activeMonitors
|
||||
for (let i = 0; i < jobs.length; i++) {
|
||||
const job = jobs[i];
|
||||
const monitor = activeMonitors.find((m) => m.hash === job.name);
|
||||
if (!monitor) {
|
||||
job.stop();
|
||||
jobs.splice(i, 1);
|
||||
i--;
|
||||
console.log("REMOVING JOB WITH NAME " + job.name);
|
||||
}
|
||||
}
|
||||
//stop and remove jobs not present in activeMonitors
|
||||
for (let i = 0; i < jobs.length; i++) {
|
||||
const job = jobs[i];
|
||||
const monitor = activeMonitors.find((m) => m.hash === job.name);
|
||||
if (!monitor) {
|
||||
job.stop();
|
||||
jobs.splice(i, 1);
|
||||
i--;
|
||||
console.log("REMOVING JOB WITH NAME " + job.name);
|
||||
}
|
||||
}
|
||||
|
||||
//add new jobs from activeMonitors if not present already in jobs
|
||||
for (let i = 0; i < activeMonitors.length; i++) {
|
||||
const monitor = activeMonitors[i];
|
||||
const job = jobs.find((j) => j.name === monitor.hash);
|
||||
if (!job) {
|
||||
const j = Cron(
|
||||
monitor.cron,
|
||||
async () => {
|
||||
await Minuter(monitor);
|
||||
},
|
||||
{
|
||||
protect: true,
|
||||
name: monitor.hash
|
||||
}
|
||||
);
|
||||
console.log("ADDING NEW JOB WITH NAME " + j.name);
|
||||
j.trigger();
|
||||
jobs.push(j);
|
||||
}
|
||||
}
|
||||
//add new jobs from activeMonitors if not present already in jobs
|
||||
for (let i = 0; i < activeMonitors.length; i++) {
|
||||
const monitor = activeMonitors[i];
|
||||
const job = jobs.find((j) => j.name === monitor.hash);
|
||||
if (!job) {
|
||||
const j = Cron(
|
||||
monitor.cron,
|
||||
async () => {
|
||||
await Minuter(monitor);
|
||||
},
|
||||
{
|
||||
protect: true,
|
||||
name: monitor.hash,
|
||||
},
|
||||
);
|
||||
console.log("ADDING NEW JOB WITH NAME " + j.name);
|
||||
j.trigger();
|
||||
jobs.push(j);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function Startup() {
|
||||
const mainJob = Cron(
|
||||
"* * * * *",
|
||||
async () => {
|
||||
await jobSchedule();
|
||||
},
|
||||
{
|
||||
protect: true,
|
||||
name: "Main Job"
|
||||
}
|
||||
);
|
||||
const mainJob = Cron(
|
||||
"* * * * *",
|
||||
async () => {
|
||||
await jobSchedule();
|
||||
},
|
||||
{
|
||||
protect: true,
|
||||
name: "Main Job",
|
||||
},
|
||||
);
|
||||
|
||||
mainJob.trigger();
|
||||
mainJob.trigger();
|
||||
|
||||
figlet("Kener is UP!", function (err, data) {
|
||||
if (err) {
|
||||
console.log("Something went wrong...");
|
||||
return;
|
||||
}
|
||||
console.log(data);
|
||||
});
|
||||
figlet("Kener is UP!", function (err, data) {
|
||||
if (err) {
|
||||
console.log("Something went wrong...");
|
||||
return;
|
||||
}
|
||||
console.log(data);
|
||||
});
|
||||
}
|
||||
|
||||
// Call Startup() if not imported as a module
|
||||
@@ -83,7 +83,7 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
if (process.argv[1] === __filename) {
|
||||
Startup();
|
||||
Startup();
|
||||
}
|
||||
|
||||
export default Startup;
|
||||
|
||||
@@ -1,154 +1,155 @@
|
||||
// @ts-nocheck
|
||||
import {
|
||||
GetMinuteStartNowTimestampUTC,
|
||||
GetNowTimestampUTC,
|
||||
GetMinuteStartTimestampUTC,
|
||||
ParseUptime,
|
||||
GetDayStartTimestampUTC
|
||||
GetMinuteStartNowTimestampUTC,
|
||||
GetNowTimestampUTC,
|
||||
GetMinuteStartTimestampUTC,
|
||||
ParseUptime,
|
||||
GetDayStartTimestampUTC,
|
||||
} from "./tool.js";
|
||||
import db from "./db/db.js";
|
||||
import { WEBHOOK } from "./constants.js";
|
||||
|
||||
import { GetMonitors, VerifyAPIKey } from "./controllers/controller.js";
|
||||
|
||||
const GetAllTags = async function () {
|
||||
let tags = [];
|
||||
let monitors = [];
|
||||
try {
|
||||
monitors = await GetMonitors({
|
||||
status: "ACTIVE"
|
||||
});
|
||||
tags = monitors.map((monitor) => monitor.tag);
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
return tags;
|
||||
let tags = [];
|
||||
let monitors = [];
|
||||
try {
|
||||
monitors = await GetMonitors({
|
||||
status: "ACTIVE",
|
||||
});
|
||||
tags = monitors.map((monitor) => monitor.tag);
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
return tags;
|
||||
};
|
||||
const CheckIfValidTag = async function (tag) {
|
||||
let tags = [];
|
||||
try {
|
||||
let monitor = await db.getMonitorByTag(tag);
|
||||
if (!!!monitor) {
|
||||
throw new Error("not a valid tag");
|
||||
}
|
||||
if (monitor.status != "ACTIVE") {
|
||||
throw new Error("monitor is not active");
|
||||
}
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
let tags = [];
|
||||
try {
|
||||
let monitor = await db.getMonitorByTag(tag);
|
||||
if (!!!monitor) {
|
||||
throw new Error("not a valid tag");
|
||||
}
|
||||
if (monitor.status != "ACTIVE") {
|
||||
throw new Error("monitor is not active");
|
||||
}
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const auth = async function (request) {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
const authToken = authHeader?.replace("Bearer ", "");
|
||||
const authHeader = request.headers.get("authorization");
|
||||
const authToken = authHeader?.replace("Bearer ", "");
|
||||
|
||||
if ((await VerifyAPIKey(authToken)) === false) {
|
||||
return new Error("invalid token");
|
||||
}
|
||||
if ((await VerifyAPIKey(authToken)) === false) {
|
||||
return new Error("invalid token");
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
};
|
||||
const store = async function (data) {
|
||||
const tag = data.tag;
|
||||
//remove Bearer from start in authHeader
|
||||
const tag = data.tag;
|
||||
//remove Bearer from start in authHeader
|
||||
|
||||
const resp = {};
|
||||
if (data.status === undefined || ["UP", "DOWN", "DEGRADED"].indexOf(data.status) === -1) {
|
||||
return { error: "status missing", status: 400 };
|
||||
}
|
||||
if (data.latency === undefined || isNaN(data.latency)) {
|
||||
return { error: "latency missing or not a number", status: 400 };
|
||||
}
|
||||
if (data.timestampInSeconds !== undefined && isNaN(data.timestampInSeconds)) {
|
||||
return { error: "timestampInSeconds not a number", status: 400 };
|
||||
}
|
||||
if (data.timestampInSeconds === undefined) {
|
||||
data.timestampInSeconds = GetNowTimestampUTC();
|
||||
}
|
||||
data.timestampInSeconds = GetMinuteStartTimestampUTC(data.timestampInSeconds);
|
||||
resp.status = data.status;
|
||||
resp.latency = data.latency;
|
||||
resp.type = "webhook";
|
||||
let timestamp = GetMinuteStartNowTimestampUTC();
|
||||
try {
|
||||
//throw error if timestamp is future or older than 90days
|
||||
if (data.timestampInSeconds > timestamp) {
|
||||
throw new Error("timestampInSeconds is in future");
|
||||
}
|
||||
//past 90 days only
|
||||
if (timestamp - data.timestampInSeconds > 90 * 24 * 60 * 60) {
|
||||
throw new Error("timestampInSeconds is older than 90days");
|
||||
}
|
||||
} catch (err) {
|
||||
return { error: err.message, status: 400 };
|
||||
}
|
||||
//check if tag is valid
|
||||
let monitor = await db.getMonitorByTag(tag);
|
||||
if (!!!monitor) {
|
||||
return { error: "no monitor with tag found", status: 400 };
|
||||
}
|
||||
if (monitor.status != "ACTIVE") {
|
||||
return { error: "monitor with the given tag is not active", status: 400 };
|
||||
}
|
||||
const resp = {};
|
||||
if (data.status === undefined || ["UP", "DOWN", "DEGRADED"].indexOf(data.status) === -1) {
|
||||
return { error: "status missing", status: 400 };
|
||||
}
|
||||
if (data.latency === undefined || isNaN(data.latency)) {
|
||||
return { error: "latency missing or not a number", status: 400 };
|
||||
}
|
||||
if (data.timestampInSeconds !== undefined && isNaN(data.timestampInSeconds)) {
|
||||
return { error: "timestampInSeconds not a number", status: 400 };
|
||||
}
|
||||
if (data.timestampInSeconds === undefined) {
|
||||
data.timestampInSeconds = GetNowTimestampUTC();
|
||||
}
|
||||
data.timestampInSeconds = GetMinuteStartTimestampUTC(data.timestampInSeconds);
|
||||
resp.status = data.status;
|
||||
resp.latency = data.latency;
|
||||
resp.type = WEBHOOK;
|
||||
let timestamp = GetMinuteStartNowTimestampUTC();
|
||||
try {
|
||||
//throw error if timestamp is future or older than 90days
|
||||
if (data.timestampInSeconds > timestamp) {
|
||||
throw new Error("timestampInSeconds is in future");
|
||||
}
|
||||
//past 90 days only
|
||||
if (timestamp - data.timestampInSeconds > 90 * 24 * 60 * 60) {
|
||||
throw new Error("timestampInSeconds is older than 90days");
|
||||
}
|
||||
} catch (err) {
|
||||
return { error: err.message, status: 400 };
|
||||
}
|
||||
//check if tag is valid
|
||||
let monitor = await db.getMonitorByTag(tag);
|
||||
if (!!!monitor) {
|
||||
return { error: "no monitor with tag found", status: 400 };
|
||||
}
|
||||
if (monitor.status != "ACTIVE") {
|
||||
return { error: "monitor with the given tag is not active", status: 400 };
|
||||
}
|
||||
|
||||
//get the monitor object matching the tag
|
||||
//get the monitor object matching the tag
|
||||
|
||||
await db.insertMonitoringData({
|
||||
monitor_tag: tag,
|
||||
timestamp: data.timestampInSeconds,
|
||||
status: resp.status,
|
||||
latency: resp.latency,
|
||||
type: "webhook"
|
||||
});
|
||||
return { status: 200, message: "success at " + data.timestampInSeconds };
|
||||
await db.insertMonitoringData({
|
||||
monitor_tag: tag,
|
||||
timestamp: data.timestampInSeconds,
|
||||
status: resp.status,
|
||||
latency: resp.latency,
|
||||
type: "webhook",
|
||||
});
|
||||
return { status: 200, message: "success at " + data.timestampInSeconds };
|
||||
};
|
||||
|
||||
const GetMonitorStatusByTag = async function (tag, timestamp) {
|
||||
let monitor = await db.getMonitorByTag(tag);
|
||||
if (!!!monitor) {
|
||||
return { error: "no monitor with tag found", status: 400 };
|
||||
}
|
||||
if (monitor.status != "ACTIVE") {
|
||||
return { error: "monitor with the given tag is not active", status: 400 };
|
||||
}
|
||||
const resp = {
|
||||
status: null,
|
||||
uptime: null,
|
||||
last_updated_at: null
|
||||
};
|
||||
let monitor = await db.getMonitorByTag(tag);
|
||||
if (!!!monitor) {
|
||||
return { error: "no monitor with tag found", status: 400 };
|
||||
}
|
||||
if (monitor.status != "ACTIVE") {
|
||||
return { error: "monitor with the given tag is not active", status: 400 };
|
||||
}
|
||||
const resp = {
|
||||
status: null,
|
||||
uptime: null,
|
||||
last_updated_at: null,
|
||||
};
|
||||
|
||||
const { include_degraded_in_downtime } = monitor;
|
||||
const { include_degraded_in_downtime } = monitor;
|
||||
|
||||
let now = GetMinuteStartNowTimestampUTC();
|
||||
if (timestamp !== null && timestamp !== undefined) {
|
||||
now = timestamp;
|
||||
}
|
||||
let start = GetDayStartTimestampUTC(now);
|
||||
let now = GetMinuteStartNowTimestampUTC();
|
||||
if (timestamp !== null && timestamp !== undefined) {
|
||||
now = timestamp;
|
||||
}
|
||||
let start = GetDayStartTimestampUTC(now);
|
||||
|
||||
let dayDataNew = await db.getMonitoringData(tag, start, now);
|
||||
let ups = 0;
|
||||
let downs = 0;
|
||||
let degradeds = 0;
|
||||
let lastData = dayDataNew[dayDataNew.length - 1];
|
||||
let dayDataNew = await db.getMonitoringData(tag, start, now);
|
||||
let ups = 0;
|
||||
let downs = 0;
|
||||
let degradeds = 0;
|
||||
let lastData = dayDataNew[dayDataNew.length - 1];
|
||||
|
||||
for (let i = 0; i < dayDataNew.length; i++) {
|
||||
let row = dayDataNew[i];
|
||||
if (row.status == "UP") {
|
||||
ups++;
|
||||
} else if (row.status == "DEGRADED") {
|
||||
degradeds++;
|
||||
} else if (row.status == "DOWN") {
|
||||
downs++;
|
||||
}
|
||||
}
|
||||
let numerator = ups + degradeds;
|
||||
if (include_degraded_in_downtime === "YES") {
|
||||
numerator = ups;
|
||||
}
|
||||
for (let i = 0; i < dayDataNew.length; i++) {
|
||||
let row = dayDataNew[i];
|
||||
if (row.status == "UP") {
|
||||
ups++;
|
||||
} else if (row.status == "DEGRADED") {
|
||||
degradeds++;
|
||||
} else if (row.status == "DOWN") {
|
||||
downs++;
|
||||
}
|
||||
}
|
||||
let numerator = ups + degradeds;
|
||||
if (include_degraded_in_downtime === "YES") {
|
||||
numerator = ups;
|
||||
}
|
||||
|
||||
resp.uptime = ParseUptime(numerator, ups + degradeds + downs);
|
||||
resp.last_updated_at = Number(lastData.timestamp);
|
||||
resp.status = lastData.status;
|
||||
return { status: 200, ...resp };
|
||||
resp.uptime = ParseUptime(numerator, ups + degradeds + downs);
|
||||
resp.last_updated_at = Number(lastData.timestamp);
|
||||
resp.status = lastData.status;
|
||||
return { status: 200, ...resp };
|
||||
};
|
||||
export { store, auth, CheckIfValidTag, GetAllTags, GetMonitorStatusByTag };
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
<script>
|
||||
import "../../app.postcss"
|
||||
import "../../kener.css"
|
||||
import "../../docs.css"
|
||||
import { Button } from "$lib/components/ui/button"
|
||||
import Sun from "lucide-svelte/icons/sun"
|
||||
import Moon from "lucide-svelte/icons/moon"
|
||||
import { onMount } from "svelte"
|
||||
import { base } from "$app/paths"
|
||||
let defaultTheme = "light"
|
||||
export let data
|
||||
let siteStructure = data.siteStructure
|
||||
let sidebar = siteStructure.sidebar
|
||||
let docFilePath = data.docFilePath
|
||||
let tableOfContents = []
|
||||
import "../../app.postcss";
|
||||
import "../../kener.css";
|
||||
import "../../docs.css";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import Sun from "lucide-svelte/icons/sun";
|
||||
import Menu from "lucide-svelte/icons/menu";
|
||||
import X from "lucide-svelte/icons/x";
|
||||
import { clickOutsideAction, slide } from "svelte-legos";
|
||||
import Moon from "lucide-svelte/icons/moon";
|
||||
import { onMount } from "svelte";
|
||||
import { base } from "$app/paths";
|
||||
let defaultTheme = "light";
|
||||
export let data;
|
||||
let siteStructure = data.siteStructure;
|
||||
let sidebar = siteStructure.sidebar;
|
||||
let docFilePath = data.docFilePath;
|
||||
let tableOfContents = [];
|
||||
|
||||
function setTheme() {
|
||||
document.documentElement.classList.add("dark")
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
|
||||
function activateSidebar(selectedDoc) {
|
||||
for (let i = 0; i < sidebar.length; i++) {
|
||||
const item = sidebar[i]
|
||||
const item = sidebar[i];
|
||||
for (let j = 0; j < item.children.length; j++) {
|
||||
const subItem = item.children[j]
|
||||
const subItem = item.children[j];
|
||||
if (subItem.file == selectedDoc) {
|
||||
subItem.active = true
|
||||
sidebar[i].children[j].active = true
|
||||
subItem.active = true;
|
||||
sidebar[i].children[j].active = true;
|
||||
} else {
|
||||
subItem.active = false
|
||||
sidebar[i].children[j].active = false
|
||||
subItem.active = false;
|
||||
sidebar[i].children[j].active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,18 +39,24 @@
|
||||
|
||||
function pageChange(e) {
|
||||
if (e.detail.docFilePath) {
|
||||
activateSidebar(e.detail.docFilePath)
|
||||
activateSidebar(e.detail.docFilePath);
|
||||
}
|
||||
}
|
||||
function updateTableOfContents(e) {
|
||||
if (e.detail.rightbar) {
|
||||
tableOfContents = e.detail.rightbar
|
||||
tableOfContents = e.detail.rightbar;
|
||||
}
|
||||
}
|
||||
let sideBarHidden = true;
|
||||
//if desktop show sidebar by default
|
||||
|
||||
let isMounted = false;
|
||||
onMount(() => {
|
||||
setTheme()
|
||||
})
|
||||
setTheme();
|
||||
if (window.innerWidth > 768) {
|
||||
sideBarHidden = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window on:pagechange={pageChange} on:rightbar={updateTableOfContents} />
|
||||
@@ -57,13 +66,13 @@
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-Q3MLRXCBFT"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || []
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments)
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag("js", new Date())
|
||||
gtag("js", new Date());
|
||||
|
||||
gtag("config", "G-Q3MLRXCBFT")
|
||||
gtag("config", "G-Q3MLRXCBFT");
|
||||
</script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
@@ -71,12 +80,10 @@
|
||||
/>
|
||||
|
||||
<!-- Highlight.js JS -->
|
||||
<script
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"
|
||||
></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
|
||||
</svelte:head>
|
||||
<div class="dark">
|
||||
<nav class="z-2 fixed left-0 right-0 top-0 z-30 h-16 bg-card">
|
||||
<nav class="fixed left-0 right-0 top-0 z-40 h-16 bg-card">
|
||||
<div class="mx-auto h-full border-b bg-card px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-full items-center justify-between">
|
||||
<!-- Logo/Brand -->
|
||||
@@ -85,7 +92,7 @@
|
||||
<!-- Document Icon - Replace with your own logo -->
|
||||
<img src="https://kener.ing/logo.png" class="h-8 w-8" alt="" />
|
||||
<span class="text-xl font-medium">Kener Documentation</span>
|
||||
<span class="me-2 rounded border px-2.5 py-0.5 text-xs font-medium"> 3.0.12 </span>
|
||||
<span class="me-2 rounded border px-2.5 py-0.5 text-xs font-medium"> 3.1.7 </span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -99,26 +106,25 @@
|
||||
/>
|
||||
</a>
|
||||
<a href="/api-reference" class="text-sm font-medium"> API Reference </a>
|
||||
<a href="https://github.com/rajnandan1/kener/issues" class="text-sm font-medium">
|
||||
Report Issue
|
||||
</a>
|
||||
<a href="https://github.com/sponsors/rajnandan1" class="text-sm font-medium">
|
||||
Sponsor
|
||||
</a>
|
||||
<a href="https://github.com/rajnandan1/kener/issues" class="text-sm font-medium"> Report Issue </a>
|
||||
<a href="https://github.com/sponsors/rajnandan1" class="text-sm font-medium"> Sponsor </a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<div class="md:hidden">
|
||||
<button type="button" class="hover: text-muted-foreground">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 hover:text-muted-foreground"
|
||||
on:click={() => {
|
||||
sideBarHidden = !sideBarHidden;
|
||||
}}
|
||||
>
|
||||
{#if sideBarHidden}
|
||||
<Menu class="h-6 w-6" />
|
||||
{:else}
|
||||
<X class="h-6 w-6" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,34 +132,40 @@
|
||||
</nav>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="z-2 fixed bottom-0 left-0 top-16 w-72 overflow-y-auto">
|
||||
<nav class="border-r bg-card p-6">
|
||||
<!-- Getting Started Section -->
|
||||
{#each sidebar as item}
|
||||
<div class="mb-4">
|
||||
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{item.sectionTitle}
|
||||
</h3>
|
||||
<div class="">
|
||||
{#each item.children as child}
|
||||
<a
|
||||
href={child.link.startsWith("/") ? base + child.link : child.link}
|
||||
class="sidebar-item group flex items-center rounded-md px-3 py-2 text-sm font-medium {!!child.active
|
||||
? 'active'
|
||||
: ''}"
|
||||
>
|
||||
{child.title}
|
||||
</a>
|
||||
{/each}
|
||||
{#if !sideBarHidden}
|
||||
<aside
|
||||
transition:slide={{ direction: "left", duration: 200 }}
|
||||
class="fixed bottom-0 left-0 top-16 z-30 w-72 overflow-y-auto md:block"
|
||||
class:hidden={sideBarHidden}
|
||||
>
|
||||
<nav class="border-r bg-card p-6">
|
||||
<!-- Getting Started Section -->
|
||||
{#each sidebar as item}
|
||||
<div class="mb-4">
|
||||
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{item.sectionTitle}
|
||||
</h3>
|
||||
<div class="">
|
||||
{#each item.children as child}
|
||||
<a
|
||||
href={child.link.startsWith("/") ? base + child.link : child.link}
|
||||
class="sidebar-item group flex items-center rounded-md px-3 py-2 text-sm font-medium {!!child.active
|
||||
? 'active'
|
||||
: ''}"
|
||||
>
|
||||
{child.title}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</nav>
|
||||
</aside>
|
||||
{/each}
|
||||
</nav>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="z-2 dark relative ml-72 min-h-screen pt-16">
|
||||
<div class="mx-auto max-w-5xl px-4 py-10 sm:px-6 lg:px-8 lg:pr-64">
|
||||
<main class="dark relative z-10 min-h-screen pt-16 md:ml-72">
|
||||
<div class="mx-auto max-w-5xl px-4 py-10 sm:px-6 md:px-8 md:pr-64">
|
||||
<!-- Content Header -->
|
||||
<div
|
||||
class="prose prose-stone max-w-none 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"
|
||||
@@ -163,9 +175,7 @@
|
||||
</div>
|
||||
</main>
|
||||
{#if tableOfContents.length > 0}
|
||||
<div
|
||||
class="blurry-bg fixed bottom-0 right-0 top-16 hidden w-64 overflow-y-auto px-6 py-10 lg:block"
|
||||
>
|
||||
<div class="blurry-bg fixed bottom-0 right-0 top-16 z-50 hidden w-64 overflow-y-auto px-6 py-10 lg:block">
|
||||
<h4 class="mb-3 text-sm font-semibold uppercase tracking-wider">On this page</h4>
|
||||
<nav class="space-y-2">
|
||||
{#each tableOfContents as item}
|
||||
|
||||
@@ -1,63 +1,153 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { afterNavigate } from "$app/navigation";
|
||||
export let data;
|
||||
let subHeadings = [];
|
||||
let previousH2 = null;
|
||||
function fillSubHeadings() {
|
||||
subHeadings = [];
|
||||
const headings = document.querySelectorAll("#markdown h2, #markdown h3");
|
||||
headings.forEach((heading) => {
|
||||
let id = heading.textContent.replace(/[^a-z0-9]/gi, "-").toLowerCase();
|
||||
if (heading.tagName === "H2") {
|
||||
previousH2 = id;
|
||||
} else {
|
||||
id = `${previousH2}-${id}`;
|
||||
}
|
||||
heading.id = id;
|
||||
subHeadings.push({
|
||||
id,
|
||||
text: heading.textContent,
|
||||
level: heading.tagName === "H2" ? 2 : 3
|
||||
});
|
||||
});
|
||||
}
|
||||
import { onMount } from "svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-svelte";
|
||||
import { afterNavigate } from "$app/navigation";
|
||||
export let data;
|
||||
let subHeadings = [];
|
||||
let previousH2 = null;
|
||||
|
||||
afterNavigate(() => {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("pagechange", {
|
||||
bubbles: true,
|
||||
detail: {
|
||||
docFilePath: data.docFilePath
|
||||
}
|
||||
})
|
||||
);
|
||||
fillSubHeadings();
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("rightbar", {
|
||||
bubbles: true,
|
||||
detail: {
|
||||
rightbar: subHeadings
|
||||
}
|
||||
})
|
||||
);
|
||||
hljs.highlightAll();
|
||||
});
|
||||
onMount(async () => {});
|
||||
function fillSubHeadings() {
|
||||
subHeadings = [];
|
||||
const headings = document.querySelectorAll("#markdown h2, #markdown h3");
|
||||
headings.forEach((heading) => {
|
||||
let id = heading.textContent.replace(/[^a-z0-9]/gi, "-").toLowerCase();
|
||||
if (heading.tagName === "H2") {
|
||||
previousH2 = id;
|
||||
} else {
|
||||
id = `${previousH2}-${id}`;
|
||||
}
|
||||
heading.id = id;
|
||||
subHeadings.push({
|
||||
id,
|
||||
text: heading.textContent,
|
||||
level: heading.tagName === "H2" ? 2 : 3
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let nextPath;
|
||||
let previousPath;
|
||||
|
||||
function scrollToId(id) {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
const y = element.getBoundingClientRect().top + window.pageYOffset - 100;
|
||||
window.scrollTo({ top: y, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
//function to iterate all h1,h2 nd h3 and add a button inside them
|
||||
function addCopyButton() {
|
||||
const headings = document.querySelectorAll("#markdown h1, #markdown h2, #markdown h3");
|
||||
headings.forEach((heading) => {
|
||||
const button = document.createElement("button");
|
||||
button.classList.add("copybtn");
|
||||
button.classList.add("relative");
|
||||
button.innerHTML = `<svg class="copy-btn left-0 top-0 absolute" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#777" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
<svg class="check-btn absolute left-0 top-0" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="green" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check"><path d="M20 6 9 17l-5-5"/></svg>`;
|
||||
button.style = "margin-left: 10px;height:16px;width:16px background-color: transparent; cursor: pointer;";
|
||||
button.onclick = () => {
|
||||
navigator.clipboard.writeText(`https://kener.ing/docs${data.docFilePath.split(".md")[0]}#` + heading.id);
|
||||
};
|
||||
heading.appendChild(button);
|
||||
});
|
||||
}
|
||||
|
||||
function handleRightBarClick(e) {
|
||||
if (e.target.tagName === "A") {
|
||||
e.preventDefault();
|
||||
const id = e.target.getAttribute("href").slice(1);
|
||||
scrollToId(id);
|
||||
}
|
||||
}
|
||||
|
||||
//function to find next and previous path
|
||||
function findNextAndPreviousPath() {
|
||||
let structure = data.siteStructure.sidebar;
|
||||
for (let i = 0; i < structure.length; i++) {
|
||||
let children = structure[i].children;
|
||||
|
||||
for (let j = 0; j < children.length; j++) {
|
||||
if (children[j].file === data.docFilePath) {
|
||||
if (j > 0) {
|
||||
previousPath = children[j - 1];
|
||||
}
|
||||
if (j < children.length - 1) {
|
||||
nextPath = children[j + 1];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterNavigate(() => {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("pagechange", {
|
||||
bubbles: true,
|
||||
detail: {
|
||||
docFilePath: data.docFilePath
|
||||
}
|
||||
})
|
||||
);
|
||||
fillSubHeadings();
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("rightbar", {
|
||||
bubbles: true,
|
||||
detail: {
|
||||
rightbar: subHeadings
|
||||
}
|
||||
})
|
||||
);
|
||||
hljs.highlightAll();
|
||||
//if hash present scroll to hash
|
||||
if (location.hash) {
|
||||
scrollToId(location.hash.slice(1));
|
||||
}
|
||||
//if hash change scroll
|
||||
window.addEventListener("hashchange", () => {
|
||||
scrollToId(location.hash.slice(1));
|
||||
});
|
||||
window.addEventListener("click", (e) => {
|
||||
handleRightBarClick(e);
|
||||
});
|
||||
addCopyButton();
|
||||
findNextAndPreviousPath();
|
||||
});
|
||||
onMount(async () => {});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.title}</title>
|
||||
<meta name="description" content={data.description} />
|
||||
<link rel="canonical" href="https://kener.ing/docs{data.docFilePath.split('.md')[0]}" />
|
||||
<title>{data.title}</title>
|
||||
<meta name="description" content={data.description} />
|
||||
<link rel="canonical" href="https://kener.ing/docs{data.docFilePath.split('.md')[0]}" />
|
||||
</svelte:head>
|
||||
|
||||
<div id="markdown">
|
||||
{@html data.md}
|
||||
{@html data.md}
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-2">
|
||||
<div class="col-span-1">
|
||||
{#if previousPath}
|
||||
<Button variant="outline" class="no-underline" href={previousPath.link}>
|
||||
<ArrowLeft class="mr-2 h-4 w-4" />
|
||||
<span>{previousPath.title}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="col-span-1 flex justify-end">
|
||||
{#if nextPath}
|
||||
<Button variant="outline" class="no-underline" href={nextPath.link}>
|
||||
<span>{nextPath.title}</span>
|
||||
<ArrowRight class="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#markdown {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
#markdown {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,160 +1,157 @@
|
||||
<script>
|
||||
import "../../app.postcss";
|
||||
import "../../kener.css";
|
||||
import Nav from "$lib/components/nav.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { base } from "$app/paths";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import Sun from "lucide-svelte/icons/sun";
|
||||
import Moon from "lucide-svelte/icons/moon";
|
||||
import { Languages } from "lucide-svelte";
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
||||
import { analyticsEvent } from "$lib/boringOne";
|
||||
import { setMode, mode, ModeWatcher } from "mode-watcher";
|
||||
export let data;
|
||||
import "../../app.postcss";
|
||||
import "../../kener.css";
|
||||
import Nav from "$lib/components/nav.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { base } from "$app/paths";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import Sun from "lucide-svelte/icons/sun";
|
||||
import Moon from "lucide-svelte/icons/moon";
|
||||
import { Languages } from "lucide-svelte";
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
||||
import { analyticsEvent } from "$lib/boringOne";
|
||||
import { setMode, mode, ModeWatcher } from "mode-watcher";
|
||||
export let data;
|
||||
|
||||
let defaultLocaleKey = data.selectedLang;
|
||||
let defaultTheme = data.site.theme;
|
||||
const allLocales = data.site.i18n?.locales.filter((locale) => locale.selected === true);
|
||||
let hasConfiguredAnalytics =
|
||||
!!data.site.analytics && data.site.analytics.filter((provider) => !!provider.id).length > 0;
|
||||
let defaultLocaleKey = data.selectedLang;
|
||||
let defaultTheme = data.site.theme;
|
||||
const allLocales = data.site.i18n?.locales.filter((locale) => locale.selected === true);
|
||||
let hasConfiguredAnalytics =
|
||||
!!data.site.analytics && data.site.analytics.filter((provider) => !!provider.id).length > 0;
|
||||
|
||||
function toggleMode() {
|
||||
if ($mode === "light") {
|
||||
setMode("dark");
|
||||
} else {
|
||||
setMode("light");
|
||||
}
|
||||
function toggleMode() {
|
||||
if ($mode === "light") {
|
||||
setMode("dark");
|
||||
} else {
|
||||
setMode("light");
|
||||
}
|
||||
|
||||
analyticsEvent("theme_change", {
|
||||
theme: $mode
|
||||
});
|
||||
}
|
||||
let defaultLocaleValue;
|
||||
if (!allLocales) {
|
||||
defaultLocaleValue = "English";
|
||||
} else {
|
||||
let lclFi = allLocales.find((locale) => locale.code === defaultLocaleKey);
|
||||
if (lclFi) {
|
||||
defaultLocaleValue = lclFi.name;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {string} locale
|
||||
*/
|
||||
function setLanguage(locale) {
|
||||
document.cookie = `localLang=${locale};max-age=${60 * 60 * 24 * 365 * 30};path=${base ? "base" : "/"};`;
|
||||
if (locale === defaultLocaleKey) return;
|
||||
defaultLocaleValue = allLocales[locale];
|
||||
analyticsEvent("language_change", {
|
||||
locale: locale
|
||||
});
|
||||
location.reload();
|
||||
}
|
||||
analyticsEvent("theme_change", {
|
||||
theme: $mode
|
||||
});
|
||||
}
|
||||
let defaultLocaleValue;
|
||||
if (!allLocales) {
|
||||
defaultLocaleValue = "English";
|
||||
} else {
|
||||
let lclFi = allLocales.find((locale) => locale.code === defaultLocaleKey);
|
||||
if (lclFi) {
|
||||
defaultLocaleValue = lclFi.name;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {string} locale
|
||||
*/
|
||||
function setLanguage(locale) {
|
||||
document.cookie = `localLang=${locale};max-age=${60 * 60 * 24 * 365 * 30};path=${base ? "base" : "/"};`;
|
||||
if (locale === defaultLocaleKey) return;
|
||||
defaultLocaleValue = allLocales[locale];
|
||||
analyticsEvent("language_change", {
|
||||
locale: locale
|
||||
});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
let Analytics;
|
||||
let Analytics;
|
||||
|
||||
onMount(async () => {
|
||||
let localTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (localTz != data.localTz) {
|
||||
if (data.isBot === false) {
|
||||
document.cookie =
|
||||
"localTz=" +
|
||||
localTz +
|
||||
";max-age=" +
|
||||
60 * 60 * 24 * 365 * 30 +
|
||||
`;path=${base ? "base" : "/"};`;
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
if (defaultTheme != "none") {
|
||||
setMode(defaultTheme);
|
||||
}
|
||||
//
|
||||
const providers = data.site.analytics;
|
||||
const analyticsPlugins = [];
|
||||
if (hasConfiguredAnalytics) {
|
||||
//loop object
|
||||
onMount(async () => {
|
||||
let localTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (localTz != data.localTz) {
|
||||
if (data.isBot === false) {
|
||||
document.cookie =
|
||||
"localTz=" + localTz + ";max-age=" + 60 * 60 * 24 * 365 * 30 + `;path=${base ? "base" : "/"};`;
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
if (defaultTheme != "none") {
|
||||
setMode(defaultTheme);
|
||||
}
|
||||
//
|
||||
const providers = data.site.analytics;
|
||||
const analyticsPlugins = [];
|
||||
if (hasConfiguredAnalytics) {
|
||||
//loop object
|
||||
|
||||
for (let i = 0; i < providers.length; i++) {
|
||||
const provider = providers[i];
|
||||
if (!!!provider.id) {
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < providers.length; i++) {
|
||||
const provider = providers[i];
|
||||
if (!!!provider.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (provider.type == "GA") {
|
||||
analyticsPlugins.push(
|
||||
analyticsGa.default({
|
||||
measurementIds: provider.id
|
||||
})
|
||||
);
|
||||
} else if (provider.type == "AMPLITUDE") {
|
||||
analyticsPlugins.push(
|
||||
analyticsAmplitude({
|
||||
apiKey: provider.measurementIds[0],
|
||||
options: {
|
||||
trackingOptions: {
|
||||
ip_address: false
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
} else if (provider.type == "MIXPANEL") {
|
||||
analyticsPlugins.push(
|
||||
analyticsMixpanel({
|
||||
token: provider.measurementIds[0]
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasConfiguredAnalytics) {
|
||||
Analytics = _analytics.init({
|
||||
app: "kener",
|
||||
debug: true,
|
||||
version: 100,
|
||||
plugins: analyticsPlugins
|
||||
});
|
||||
Analytics.page();
|
||||
}
|
||||
if (provider.type == "GA") {
|
||||
analyticsPlugins.push(
|
||||
analyticsGa.default({
|
||||
measurementIds: provider.id
|
||||
})
|
||||
);
|
||||
} else if (provider.type == "AMPLITUDE") {
|
||||
analyticsPlugins.push(
|
||||
analyticsAmplitude({
|
||||
apiKey: provider.measurementIds[0],
|
||||
options: {
|
||||
trackingOptions: {
|
||||
ip_address: false
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
} else if (provider.type == "MIXPANEL") {
|
||||
analyticsPlugins.push(
|
||||
analyticsMixpanel({
|
||||
token: provider.measurementIds[0]
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasConfiguredAnalytics) {
|
||||
Analytics = _analytics.init({
|
||||
app: "kener",
|
||||
debug: true,
|
||||
version: 100,
|
||||
plugins: analyticsPlugins
|
||||
});
|
||||
Analytics.page();
|
||||
}
|
||||
|
||||
if (!!data.bgc && data.bgc[0] == "#") {
|
||||
document.body.style.backgroundColor = data.bgc;
|
||||
}
|
||||
});
|
||||
function captureAnalytics(e) {
|
||||
if (hasConfiguredAnalytics) {
|
||||
const { event, data } = e.detail;
|
||||
Analytics.track(event, data);
|
||||
}
|
||||
}
|
||||
let customCSS = `<style>${data.site.customCSS}</style>`;
|
||||
if (!!data.bgc && data.bgc[0] == "#") {
|
||||
document.body.style.backgroundColor = data.bgc;
|
||||
}
|
||||
});
|
||||
function captureAnalytics(e) {
|
||||
if (hasConfiguredAnalytics) {
|
||||
const { event, data } = e.detail;
|
||||
Analytics.track(event, data);
|
||||
}
|
||||
}
|
||||
let customCSS = `<style>${data.site.customCSS}</style>`;
|
||||
if (!!data.site.favicon && !data.site.favicon.startsWith("http")) data.site.favicon = `${base}${data.site.favicon}`;
|
||||
</script>
|
||||
|
||||
<svelte:window on:analyticsEvent={captureAnalytics} />
|
||||
<svelte:head>
|
||||
{#if data.site.favicon}
|
||||
<link rel="icon" id="kener-app-favicon" href={base + data.site.favicon} />
|
||||
{/if}
|
||||
{#if !!data.site.font.cssSrc}
|
||||
<link href={data.site.font.cssSrc} rel="stylesheet" />
|
||||
{/if}
|
||||
{#if data.site.favicon}
|
||||
<link rel="icon" id="kener-app-favicon" href={data.site.favicon} />
|
||||
{/if}
|
||||
{#if !!data.site.font.cssSrc}
|
||||
<link href={data.site.font.cssSrc} rel="stylesheet" />
|
||||
{/if}
|
||||
|
||||
{#if hasConfiguredAnalytics}
|
||||
<script src="https://unpkg.com/analytics/dist/analytics.min.js"></script>
|
||||
{#each data.site.analytics as { id, type, script }}
|
||||
{#if !!id}
|
||||
<script data-type={type} src={script}></script>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{#if !!data.site.customCSS}
|
||||
{@html customCSS}
|
||||
{/if}
|
||||
{#if hasConfiguredAnalytics}
|
||||
<script src="https://unpkg.com/analytics/dist/analytics.min.js"></script>
|
||||
{#each data.site.analytics as { id, type, script }}
|
||||
{#if !!id}
|
||||
<script data-type={type} src={script}></script>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{#if !!data.site.customCSS}
|
||||
{@html customCSS}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
<ModeWatcher />
|
||||
<main
|
||||
style="
|
||||
style="
|
||||
--font-family: {data.site.font.family};
|
||||
--bg-custom: {data.bgc};
|
||||
--up-color: {data.site.colors.UP};
|
||||
@@ -162,75 +159,64 @@
|
||||
--degraded-color: {data.site.colors.DEGRADED}
|
||||
"
|
||||
>
|
||||
<Nav {data} />
|
||||
<div class="main-content min-h-[70vh]">
|
||||
<slot />
|
||||
</div>
|
||||
<Nav {data} />
|
||||
<div class="main-content min-h-[70vh]">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
{#if !!data.site.footerHTML}
|
||||
<footer class="z-10 py-6 md:px-8 md:py-0">
|
||||
<div
|
||||
class="container relative flex max-w-[655px] flex-col items-center justify-center gap-4 pl-0 md:h-24 md:flex-row"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-4 px-8 md:flex-row md:gap-2 md:px-0">
|
||||
<p class="text-center text-sm leading-loose text-muted-foreground md:text-left">
|
||||
{@html data.site.footerHTML}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
{/if}
|
||||
<div class="fixed bottom-4 right-4 z-20 rounded-md bg-background">
|
||||
{#if data.site.i18n && data.site.i18n.locales && allLocales.length > 1}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="ghost" size="icon" class="flex">
|
||||
<Languages class="h-[1.2rem] w-[1.2rem]" />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
{#each allLocales as locale}
|
||||
<DropdownMenu.Item
|
||||
class={defaultLocaleKey == locale.code ? "bg-input" : ""}
|
||||
on:click={(e) => {
|
||||
setLanguage(locale.code);
|
||||
}}>{locale.name}</DropdownMenu.Item
|
||||
>
|
||||
{/each}
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
{#if !!data.site.themeToggle}
|
||||
<Button on:click={toggleMode} variant="ghost" size="icon" class="flex">
|
||||
<Sun
|
||||
class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"
|
||||
/>
|
||||
<Moon
|
||||
class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"
|
||||
/>
|
||||
<span class="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if data.isLoggedIn}
|
||||
<a
|
||||
href="{base}/manage/app/site"
|
||||
on:click|preventDefault={(e) => {
|
||||
location.href = `${base}/manage/app/site`;
|
||||
}}
|
||||
class="button-77 fixed bottom-8 left-8 z-50"
|
||||
role="button"
|
||||
>
|
||||
Manage
|
||||
</a>
|
||||
{/if}
|
||||
{#if !!data.site.footerHTML}
|
||||
<footer class="z-10 py-6 md:px-8 md:py-0">
|
||||
<div
|
||||
class="container relative flex max-w-[655px] flex-col items-center justify-center gap-4 pl-0 md:h-24 md:flex-row"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-4 px-8 md:flex-row md:gap-2 md:px-0">
|
||||
<p class="text-center text-sm leading-loose text-muted-foreground md:text-left">
|
||||
{@html data.site.footerHTML}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
{/if}
|
||||
<div class="fixed bottom-4 right-4 z-20 rounded-md bg-background">
|
||||
{#if data.site.i18n && data.site.i18n.locales && allLocales.length > 1}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="ghost" size="icon" class="flex">
|
||||
<Languages class="h-[1.2rem] w-[1.2rem]" />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
{#each allLocales as locale}
|
||||
<DropdownMenu.Item
|
||||
class={defaultLocaleKey == locale.code ? "bg-input" : ""}
|
||||
on:click={(e) => {
|
||||
setLanguage(locale.code);
|
||||
}}>{locale.name}</DropdownMenu.Item
|
||||
>
|
||||
{/each}
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
{#if !!data.site.themeToggle}
|
||||
<Button on:click={toggleMode} variant="ghost" size="icon" class="flex">
|
||||
<Sun class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span class="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if data.isLoggedIn}
|
||||
<a href="{base}/manage/app/site" rel="external" class="button-77 fixed bottom-8 left-8 z-50" role="button">
|
||||
Manage
|
||||
</a>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
/* Apply the global font family using the CSS variable */
|
||||
* {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
/* Apply the global font family using the CSS variable */
|
||||
* {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,127 +4,196 @@ import { GetMonitors, GetIncidentsOpenHome } from "$lib/server/controllers/contr
|
||||
import { SortMonitor } from "$lib/clientTools.js";
|
||||
import moment from "moment";
|
||||
function removeTags(str) {
|
||||
if (str === null || str === "") return false;
|
||||
else str = str.toString();
|
||||
if (str === null || str === "") return false;
|
||||
else str = str.toString();
|
||||
|
||||
// Regular expression to identify HTML tags in
|
||||
// the input string. Replacing the identified
|
||||
// HTML tag with a null string.
|
||||
return str.replace(/(<([^>]+)>)/gi, "");
|
||||
return str.replace(/(<([^>]+)>)/gi, "");
|
||||
}
|
||||
|
||||
async function returnTypeOfMonitorsPageMeta(url) {
|
||||
const query = url.searchParams;
|
||||
let filter = {
|
||||
status: "ACTIVE",
|
||||
};
|
||||
|
||||
let pageType = "home";
|
||||
let group;
|
||||
if (!!query.get("category") && query.get("category") !== "Home") {
|
||||
filter.category_name = query.get("category");
|
||||
pageType = "category";
|
||||
}
|
||||
|
||||
if (!!query.get("monitor")) {
|
||||
filter.tag = query.get("monitor");
|
||||
pageType = "monitor";
|
||||
}
|
||||
|
||||
if (!!query.get("group")) {
|
||||
let g = await GetMonitors({ status: "ACTIVE", tag: query.get("group") });
|
||||
if (g.length > 0) {
|
||||
group = g[0];
|
||||
let typeData = JSON.parse(g[0].type_data);
|
||||
let groupPresentMonitorTags = typeData.monitors.map((monitor) => monitor.tag);
|
||||
filter.tags = groupPresentMonitorTags;
|
||||
pageType = "group";
|
||||
}
|
||||
}
|
||||
|
||||
let monitors = await GetMonitors(filter);
|
||||
return { monitors, pageType, group };
|
||||
}
|
||||
|
||||
export async function load({ parent, url }) {
|
||||
let monitors = await GetMonitors({ status: "ACTIVE" });
|
||||
const query = url.searchParams;
|
||||
const requiredCategory = query.get("category") || "Home";
|
||||
const parentData = await parent();
|
||||
const siteData = parentData.site;
|
||||
let pageTitle = siteData.title;
|
||||
let canonical = siteData.siteURL;
|
||||
let pageDescription = siteData.metaTags.find((tag) => tag.key === "description").value;
|
||||
monitors = SortMonitor(siteData.monitorSort, monitors);
|
||||
const monitorsActive = [];
|
||||
for (let i = 0; i < monitors.length; i++) {
|
||||
//only return monitors that have category as home or category is not present
|
||||
if (
|
||||
!!!query.get("monitor") &&
|
||||
!!monitors[i].category_name &&
|
||||
monitors[i].category_name !== requiredCategory
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (query.get("monitor") && query.get("monitor") !== monitors[i].tag) {
|
||||
continue;
|
||||
}
|
||||
delete monitors[i].api;
|
||||
delete monitors[i].default_status;
|
||||
const query = url.searchParams;
|
||||
|
||||
let data = await FetchData(
|
||||
siteData,
|
||||
monitors[i],
|
||||
parentData.localTz,
|
||||
parentData.selectedLang,
|
||||
parentData.lang
|
||||
);
|
||||
monitors[i].pageData = data;
|
||||
let { monitors, pageType, group } = await returnTypeOfMonitorsPageMeta(url);
|
||||
let hiddenGroupedMonitorsTags = [];
|
||||
for (let i = 0; i < monitors.length; i++) {
|
||||
if (pageType === "home" && monitors[i].monitor_type === "GROUP") {
|
||||
let typeData = JSON.parse(monitors[i].type_data);
|
||||
if (typeData.monitors && typeData.hideMonitors) {
|
||||
hiddenGroupedMonitorsTags = hiddenGroupedMonitorsTags.concat(typeData.monitors.map((monitor) => monitor.tag));
|
||||
}
|
||||
}
|
||||
}
|
||||
monitors = monitors
|
||||
.map((monitor) => {
|
||||
return {
|
||||
tag: monitor.tag,
|
||||
name: monitor.name,
|
||||
description: monitor.description,
|
||||
monitor_type: monitor.monitor_type,
|
||||
image: monitor.image,
|
||||
category_name: monitor.category_name,
|
||||
day_degraded_minimum_count: monitor.day_degraded_minimum_count,
|
||||
day_down_minimum_count: monitor.day_down_minimum_count,
|
||||
id: monitor.id,
|
||||
image: monitor.image,
|
||||
include_degraded_in_downtime: monitor.include_degraded_in_downtime,
|
||||
};
|
||||
})
|
||||
.filter((monitor) => !hiddenGroupedMonitorsTags.includes(monitor.tag))
|
||||
.filter((monitor) => {
|
||||
if (pageType == "home") {
|
||||
return monitor.category_name == "Home";
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
monitors[i].activeIncidents = [];
|
||||
monitorsActive.push(monitors[i]);
|
||||
}
|
||||
let startWithin = moment().subtract(siteData.homeIncidentStartTimeWithin, "days").unix();
|
||||
let endWithin = moment().add(siteData.homeIncidentStartTimeWithin, "days").unix();
|
||||
let allOpenIncidents = await GetIncidentsOpenHome(
|
||||
siteData.homeIncidentCount,
|
||||
startWithin,
|
||||
endWithin
|
||||
);
|
||||
const parentData = await parent();
|
||||
const siteData = parentData.site;
|
||||
let hero = siteData.hero;
|
||||
|
||||
//if not home page
|
||||
let isCategoryPage = !!query.get("category") && query.get("category") !== "Home";
|
||||
let isMonitorPage = !!query.get("monitor");
|
||||
if (isMonitorPage && monitorsActive.length > 0) {
|
||||
pageTitle = monitorsActive[0].name + " - " + pageTitle;
|
||||
pageDescription = monitorsActive[0].description;
|
||||
canonical = canonical + "?monitor=" + monitorsActive[0].tag;
|
||||
}
|
||||
//if category page
|
||||
if (isCategoryPage) {
|
||||
let allCategories = siteData.categories;
|
||||
let selectedCategory = allCategories.find((category) => category.name === requiredCategory);
|
||||
if (selectedCategory) {
|
||||
pageTitle = selectedCategory.name + " - " + pageTitle;
|
||||
pageDescription = selectedCategory.description;
|
||||
canonical = canonical + "?category=" + requiredCategory;
|
||||
}
|
||||
}
|
||||
if (isCategoryPage || isMonitorPage) {
|
||||
let eligibleTags = monitorsActive.map((monitor) => monitor.tag);
|
||||
//filter incidents that have monitor_tag in monitors
|
||||
allOpenIncidents = allOpenIncidents.filter((incident) => {
|
||||
let incidentMonitors = incident.monitors;
|
||||
let monitorTags = incidentMonitors.map((monitor) => monitor.monitor_tag);
|
||||
let isPresent = false;
|
||||
monitorTags.forEach((tag) => {
|
||||
if (eligibleTags.includes(tag)) {
|
||||
isPresent = true;
|
||||
}
|
||||
});
|
||||
return isPresent;
|
||||
});
|
||||
}
|
||||
let pageTitle = siteData.title;
|
||||
let canonical = siteData.siteURL;
|
||||
let pageDescription = "";
|
||||
let descDb = siteData.metaTags.find((tag) => tag.key === "description");
|
||||
if (descDb) {
|
||||
pageDescription = descDb.value;
|
||||
}
|
||||
|
||||
allOpenIncidents = allOpenIncidents.map((incident) => {
|
||||
let incidentMonitors = incident.monitors;
|
||||
let monitorTags = incidentMonitors.map((monitor) => monitor.monitor_tag);
|
||||
let xm = monitors.filter((monitor) => monitorTags.includes(monitor.tag));
|
||||
monitors = SortMonitor(siteData.monitorSort, monitors);
|
||||
const monitorsActive = [];
|
||||
for (let i = 0; i < monitors.length; i++) {
|
||||
let data = await FetchData(siteData, monitors[i], parentData.localTz, parentData.selectedLang, parentData.lang);
|
||||
monitors[i].pageData = data;
|
||||
|
||||
incident.monitors = xm.map((monitor) => {
|
||||
return {
|
||||
tag: monitor.tag,
|
||||
name: monitor.name,
|
||||
description: monitor.description,
|
||||
image: monitor.image,
|
||||
impact_type: incidentMonitors.filter((m) => m.monitor_tag === monitor.tag)[0]
|
||||
.monitor_impact
|
||||
};
|
||||
});
|
||||
return incident;
|
||||
});
|
||||
monitors[i].activeIncidents = [];
|
||||
monitorsActive.push(monitors[i]);
|
||||
}
|
||||
|
||||
let allRecentIncidents = allOpenIncidents.filter(
|
||||
(incident) => incident.incident_type == "INCIDENT"
|
||||
);
|
||||
let allRecentMaintenances = allOpenIncidents.filter(
|
||||
(incident) => incident.incident_type == "MAINTENANCE"
|
||||
);
|
||||
return {
|
||||
monitors: monitorsActive,
|
||||
allRecentIncidents,
|
||||
allRecentMaintenances,
|
||||
categoryName: requiredCategory,
|
||||
isCategoryPage: isCategoryPage,
|
||||
isMonitorPage: isMonitorPage,
|
||||
pageTitle: pageTitle,
|
||||
pageDescription: removeTags(pageDescription),
|
||||
canonical
|
||||
};
|
||||
let startWithin = moment().subtract(siteData.homeIncidentStartTimeWithin, "days").unix();
|
||||
let endWithin = moment().add(siteData.homeIncidentStartTimeWithin, "days").unix();
|
||||
let allOpenIncidents = await GetIncidentsOpenHome(siteData.homeIncidentCount, startWithin, endWithin);
|
||||
|
||||
//if not home page
|
||||
let isCategoryPage = pageType === "category";
|
||||
let isMonitorPage = pageType === "monitor";
|
||||
let isGroupPage = pageType === "group";
|
||||
|
||||
if (isMonitorPage && monitorsActive.length > 0) {
|
||||
pageTitle = monitorsActive[0].name + " - " + pageTitle;
|
||||
pageDescription = monitorsActive[0].description;
|
||||
canonical = canonical + "?monitor=" + monitorsActive[0].tag;
|
||||
hero = {
|
||||
title: monitorsActive[0].name,
|
||||
subtitle: monitorsActive[0].description,
|
||||
image: monitorsActive[0].image,
|
||||
};
|
||||
}
|
||||
|
||||
if (isGroupPage) {
|
||||
pageTitle = group.name + " - " + pageTitle;
|
||||
pageDescription = group.description;
|
||||
canonical = canonical + "?group=" + group.tag;
|
||||
|
||||
hero = {
|
||||
title: group.name,
|
||||
subtitle: group.description,
|
||||
image: group.image,
|
||||
};
|
||||
}
|
||||
|
||||
//if category page
|
||||
if (isCategoryPage) {
|
||||
let allCategories = siteData.categories;
|
||||
let selectedCategory = allCategories.find((category) => category.name === query.get("category"));
|
||||
if (selectedCategory) {
|
||||
pageTitle = selectedCategory.name + " - " + pageTitle;
|
||||
pageDescription = selectedCategory.description;
|
||||
canonical = canonical + "?category=" + query.get("category");
|
||||
|
||||
hero = {
|
||||
title: selectedCategory.name,
|
||||
subtitle: selectedCategory.description,
|
||||
image: selectedCategory.image,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (isCategoryPage || isMonitorPage || isGroupPage) {
|
||||
let eligibleTags = monitorsActive.map((monitor) => monitor.tag);
|
||||
//filter incidents that have monitor_tag in monitors
|
||||
allOpenIncidents = allOpenIncidents.filter((incident) => {
|
||||
let incidentMonitors = incident.monitors;
|
||||
let monitorTags = incidentMonitors.map((monitor) => monitor.monitor_tag);
|
||||
let isPresent = false;
|
||||
monitorTags.forEach((tag) => {
|
||||
if (eligibleTags.includes(tag)) {
|
||||
isPresent = true;
|
||||
}
|
||||
});
|
||||
return isPresent;
|
||||
});
|
||||
}
|
||||
|
||||
allOpenIncidents = allOpenIncidents.map((incident) => {
|
||||
let incidentMonitors = incident.monitors;
|
||||
let monitorTags = incidentMonitors.map((monitor) => monitor.monitor_tag);
|
||||
let xm = monitors.filter((monitor) => monitorTags.includes(monitor.tag));
|
||||
|
||||
incident.monitors = xm.map((monitor) => {
|
||||
return {
|
||||
tag: monitor.tag,
|
||||
name: monitor.name,
|
||||
description: monitor.description,
|
||||
image: monitor.image,
|
||||
impact_type: incidentMonitors.filter((m) => m.monitor_tag === monitor.tag)[0].monitor_impact,
|
||||
};
|
||||
});
|
||||
return incident;
|
||||
});
|
||||
|
||||
let allRecentIncidents = allOpenIncidents.filter((incident) => incident.incident_type == "INCIDENT");
|
||||
let allRecentMaintenances = allOpenIncidents.filter((incident) => incident.incident_type == "MAINTENANCE");
|
||||
return {
|
||||
monitors: monitorsActive,
|
||||
allRecentIncidents,
|
||||
allRecentMaintenances,
|
||||
pageType,
|
||||
pageTitle: pageTitle,
|
||||
pageDescription: removeTags(pageDescription),
|
||||
hero,
|
||||
categoryName: query.get("category"),
|
||||
canonical,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,353 +1,284 @@
|
||||
<script>
|
||||
import Monitor from "$lib/components/monitor.svelte";
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import { Button, buttonVariants } from "$lib/components/ui/button";
|
||||
import Incident from "$lib/components/IncidentNew.svelte";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import { l } from "$lib/i18n/client";
|
||||
import { base } from "$app/paths";
|
||||
import { ArrowRight, ChevronLeft, X } from "lucide-svelte";
|
||||
import { hotKeyAction, clickOutsideAction } from "svelte-legos";
|
||||
import { onMount } from "svelte";
|
||||
import ShareMenu from "$lib/components/shareMenu.svelte";
|
||||
import { scale } from "svelte/transition";
|
||||
import { format } from "date-fns";
|
||||
import Monitor from "$lib/components/monitor.svelte";
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import { Button, buttonVariants } from "$lib/components/ui/button";
|
||||
import Incident from "$lib/components/IncidentNew.svelte";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import { l } from "$lib/i18n/client";
|
||||
import { base } from "$app/paths";
|
||||
import { ArrowRight, ChevronLeft, X } from "lucide-svelte";
|
||||
import { hotKeyAction, clickOutsideAction } from "svelte-legos";
|
||||
import { onMount } from "svelte";
|
||||
import ShareMenu from "$lib/components/shareMenu.svelte";
|
||||
import { scale } from "svelte/transition";
|
||||
import { format } from "date-fns";
|
||||
import GMI from "$lib/components/gmi.svelte";
|
||||
|
||||
export let data;
|
||||
let shareMenusToggle = false;
|
||||
function showShareMenu(e) {
|
||||
shareMenusToggle = true;
|
||||
activeMonitor = e.detail.monitor;
|
||||
}
|
||||
let activeMonitor = null;
|
||||
let pageLoaded = false;
|
||||
$: {
|
||||
if (pageLoaded) {
|
||||
if (shareMenusToggle) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "auto";
|
||||
}
|
||||
}
|
||||
}
|
||||
export let data;
|
||||
let shareMenusToggle = false;
|
||||
function showShareMenu(e) {
|
||||
shareMenusToggle = true;
|
||||
activeMonitor = e.detail.monitor;
|
||||
}
|
||||
let activeMonitor = null;
|
||||
let pageLoaded = false;
|
||||
$: {
|
||||
if (pageLoaded) {
|
||||
if (shareMenusToggle) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "auto";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let isHome = !data.isCategoryPage && !data.isMonitorPage;
|
||||
onMount(() => {
|
||||
pageLoaded = true;
|
||||
});
|
||||
let kindFilter = "INCIDENT";
|
||||
function kindOfIncidents(kind) {
|
||||
kindFilter = kind;
|
||||
}
|
||||
|
||||
if (data.isCategoryPage) {
|
||||
let category = data.site.categories.find((e) => e.name == data.categoryName);
|
||||
if (!!category) {
|
||||
data.site.hero.title = category.name;
|
||||
data.site.hero.subtitle = category.description;
|
||||
}
|
||||
} else if (data.isMonitorPage) {
|
||||
let monitor = data.monitors[0];
|
||||
if (!!monitor) {
|
||||
data.site.hero.title = monitor.name;
|
||||
data.site.hero.subtitle = monitor.description;
|
||||
data.site.hero.image = monitor.image;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
pageLoaded = true;
|
||||
});
|
||||
let kindFilter = "INCIDENT";
|
||||
function kindOfIncidents(kind) {
|
||||
kindFilter = kind;
|
||||
}
|
||||
|
||||
if (data.allRecentIncidents.length == 0) {
|
||||
kindOfIncidents("MAINTENANCE");
|
||||
}
|
||||
if (data.allRecentIncidents.length == 0) {
|
||||
kindOfIncidents("MAINTENANCE");
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.pageTitle}</title>
|
||||
{#if !!data.pageDescription}
|
||||
<meta name="description" content={data.pageDescription} />
|
||||
{/if}
|
||||
{#if !!data.canonical}
|
||||
<link rel="canonical" href={data.canonical} />
|
||||
{/if}
|
||||
{#if data.site.metaTags}
|
||||
{#each data.site.metaTags as metaTag}
|
||||
{#if metaTag.key != "description"}
|
||||
<meta name={metaTag.key} content={metaTag.value} />
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
<title>{data.pageTitle}</title>
|
||||
{#if !!data.pageDescription}
|
||||
<meta name="description" content={data.pageDescription} />
|
||||
{/if}
|
||||
{#if !!data.canonical}
|
||||
<link rel="canonical" href={data.canonical} />
|
||||
{/if}
|
||||
{#if data.site.metaTags}
|
||||
{#each data.site.metaTags as metaTag}
|
||||
{#if metaTag.key != "description"}
|
||||
<meta name={metaTag.key} content={metaTag.value} />
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
<div class="mt-12"></div>
|
||||
{#if data.site.hero}
|
||||
<section
|
||||
class="mx-auto mb-8 flex w-full max-w-[655px] flex-1 flex-col items-start justify-center"
|
||||
>
|
||||
<div class="mx-auto max-w-screen-xl px-4 lg:flex lg:items-center">
|
||||
<div class="blurry-bg mx-auto max-w-3xl text-center">
|
||||
{#if data.site.hero.image}
|
||||
<img
|
||||
src={base + data.site.hero.image}
|
||||
class="m-auto mb-2 h-14 w-14"
|
||||
alt=""
|
||||
srcset=""
|
||||
/>
|
||||
{/if}
|
||||
{#if data.site.hero.title}
|
||||
<h1
|
||||
class="bg-gradient-to-r from-green-300 via-blue-500 to-purple-600 bg-clip-text text-5xl font-extrabold leading-tight text-transparent"
|
||||
>
|
||||
{@html data.site.hero.title}
|
||||
</h1>
|
||||
{/if}
|
||||
{#if data.site.hero.subtitle}
|
||||
<h2 class="mx-auto mt-4 max-w-xl sm:text-xl">
|
||||
{@html data.site.hero.subtitle}
|
||||
</h2>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{#if data.hero}
|
||||
<section class="mx-auto mb-8 flex w-full max-w-[655px] flex-1 flex-col items-start justify-center">
|
||||
<div class="mx-auto max-w-screen-xl px-4 lg:flex lg:items-center">
|
||||
<div class="blurry-bg mx-auto max-w-3xl text-center">
|
||||
{#if data.hero.image}
|
||||
<GMI src={data.hero.image} classList="m-auto mb-2 h-14 w-14" alt="" srcset="" />
|
||||
{/if}
|
||||
{#if data.hero.title}
|
||||
<h1
|
||||
class="bg-gradient-to-r from-green-300 via-blue-500 to-purple-600 bg-clip-text text-5xl font-extrabold leading-tight text-transparent"
|
||||
>
|
||||
{@html data.hero.title}
|
||||
</h1>
|
||||
{/if}
|
||||
{#if data.hero.subtitle}
|
||||
<h2 class="mx-auto mt-4 max-w-xl sm:text-xl">
|
||||
{@html data.hero.subtitle}
|
||||
</h2>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{#if !isHome}
|
||||
<section
|
||||
class="mx-auto my-2 flex w-full max-w-[655px] flex-1 flex-col items-start justify-center"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="bounce-left h-8 justify-start pl-1.5"
|
||||
on:click={() => {
|
||||
if (data.isCategoryPage) {
|
||||
return window.history.back();
|
||||
}
|
||||
if (data.isMonitorPage) {
|
||||
return (window.location.href = `${base}/`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronLeft class="arrow mr-1 h-5 w-5" />
|
||||
{l(data.lang, "Back")}
|
||||
</Button>
|
||||
</section>
|
||||
{#if data.pageType != "home"}
|
||||
<section class="mx-auto my-2 flex w-full max-w-[655px] flex-1 flex-col items-start justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="bounce-left h-8 justify-start pl-1.5"
|
||||
on:click={() => {
|
||||
if (data.pageType == "category") {
|
||||
return window.history.back();
|
||||
} else if (data.pageType == "monitor") {
|
||||
return (window.location.href = `${base}/`);
|
||||
} else if (data.pageType == "group") {
|
||||
return (window.location.href = `${base}/`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronLeft class="arrow mr-1 h-5 w-5" />
|
||||
{l(data.lang, "Back")}
|
||||
</Button>
|
||||
</section>
|
||||
{/if}
|
||||
{#if data.allRecentIncidents.length + data.allRecentMaintenances.length > 0}
|
||||
<section
|
||||
class="mx-auto mb-2 flex w-full max-w-[655px] flex-1 flex-col items-start justify-center bg-transparent"
|
||||
id=""
|
||||
>
|
||||
<div class="grid w-full grid-cols-2 gap-4">
|
||||
<div class="col-span-2 flex gap-x-2 text-center md:text-left">
|
||||
{#if kindFilter == "INCIDENT"}
|
||||
{#if data.allRecentIncidents.length > 0}
|
||||
<Button
|
||||
class="h-8 text-sm "
|
||||
on:click={() => kindOfIncidents("INCIDENT")}
|
||||
>
|
||||
{l(data.lang, "Recent Incidents")}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if data.allRecentMaintenances.length > 0}
|
||||
<Button
|
||||
variant="secondary"
|
||||
class=" h-8 text-sm"
|
||||
on:click={() => kindOfIncidents("MAINTENANCE")}
|
||||
>
|
||||
{l(data.lang, "Recent Maintenances")}
|
||||
</Button>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if data.allRecentIncidents.length > 0}
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="h-8 text-sm "
|
||||
on:click={() => kindOfIncidents("INCIDENT")}
|
||||
>
|
||||
{l(data.lang, "Recent Incidents")}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if data.allRecentMaintenances.length > 0}
|
||||
<Button
|
||||
class="h-8 text-sm"
|
||||
on:click={() => kindOfIncidents("MAINTENANCE")}
|
||||
>
|
||||
{l(data.lang, "Recent Maintenances")}
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="mx-auto mb-8 flex w-full max-w-[655px] flex-1 flex-col items-start justify-center backdrop-blur-[2px]"
|
||||
id=""
|
||||
>
|
||||
<Card.Root class="w-full">
|
||||
{#if kindFilter == "INCIDENT"}
|
||||
<Card.Content class=" newincidents w-full overflow-hidden p-0">
|
||||
{#each data.allRecentIncidents as incident, index}
|
||||
<Incident
|
||||
{incident}
|
||||
lang={data.lang}
|
||||
index="incident-{index}"
|
||||
selectedLang={data.selectedLang}
|
||||
/>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
{:else if kindFilter == "MAINTENANCE"}
|
||||
{#each data.allRecentMaintenances as incident, index}
|
||||
<Incident
|
||||
{incident}
|
||||
lang={data.lang}
|
||||
index="incident-{index}"
|
||||
selectedLang={data.selectedLang}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</Card.Root>
|
||||
</section>
|
||||
<section
|
||||
class="mx-auto mb-2 flex w-full max-w-[655px] flex-1 flex-col items-start justify-center bg-transparent"
|
||||
id=""
|
||||
>
|
||||
<div class="grid w-full grid-cols-2 gap-4">
|
||||
<div class="col-span-2 flex gap-x-2 text-center md:text-left">
|
||||
{#if kindFilter == "INCIDENT"}
|
||||
{#if data.allRecentIncidents.length > 0}
|
||||
<Button class="h-8 text-sm " on:click={() => kindOfIncidents("INCIDENT")}>
|
||||
{l(data.lang, "Recent Incidents")}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if data.allRecentMaintenances.length > 0}
|
||||
<Button variant="secondary" class=" h-8 text-sm" on:click={() => kindOfIncidents("MAINTENANCE")}>
|
||||
{l(data.lang, "Recent Maintenances")}
|
||||
</Button>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if data.allRecentIncidents.length > 0}
|
||||
<Button variant="secondary" class="h-8 text-sm " on:click={() => kindOfIncidents("INCIDENT")}>
|
||||
{l(data.lang, "Recent Incidents")}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if data.allRecentMaintenances.length > 0}
|
||||
<Button class="h-8 text-sm" on:click={() => kindOfIncidents("MAINTENANCE")}>
|
||||
{l(data.lang, "Recent Maintenances")}
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="mx-auto mb-8 flex w-full max-w-[655px] flex-1 flex-col items-start justify-center backdrop-blur-[2px]"
|
||||
id=""
|
||||
>
|
||||
<Card.Root class="w-full">
|
||||
{#if kindFilter == "INCIDENT"}
|
||||
<Card.Content class=" newincidents w-full overflow-hidden p-0">
|
||||
{#each data.allRecentIncidents as incident, index}
|
||||
<Incident {incident} lang={data.lang} index="incident-{index}" selectedLang={data.selectedLang} />
|
||||
{/each}
|
||||
</Card.Content>
|
||||
{:else if kindFilter == "MAINTENANCE"}
|
||||
{#each data.allRecentMaintenances as incident, index}
|
||||
<Incident {incident} lang={data.lang} index="incident-{index}" selectedLang={data.selectedLang} />
|
||||
{/each}
|
||||
{/if}
|
||||
</Card.Root>
|
||||
</section>
|
||||
{/if}
|
||||
{#if data.monitors.length > 0}
|
||||
<section
|
||||
class="mx-auto mb-2 flex w-full flex-1 flex-col items-start justify-center bg-transparent md:w-[655px]"
|
||||
id=""
|
||||
>
|
||||
<div class="grid w-full grid-cols-2 gap-4">
|
||||
<div class="col-span-2 text-center md:col-span-1 md:text-left">
|
||||
<Badge class="border-0 md:pl-0" variant="outline">
|
||||
{l(data.lang, "Availability per Component")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="col-span-2 text-center md:col-span-1 md:text-right">
|
||||
<Badge variant="outline" class="border-0 md:pr-0">
|
||||
<span class="bg-api-up mr-1 inline-flex h-[8px] w-[8px] rounded-full opacity-75"
|
||||
></span>
|
||||
<span class="mr-3">
|
||||
{l(data.lang, "UP")}
|
||||
</span>
|
||||
<section
|
||||
class="mx-auto mb-2 flex w-full flex-1 flex-col items-start justify-center bg-transparent md:w-[655px]"
|
||||
id=""
|
||||
>
|
||||
<div class="grid w-full grid-cols-2 gap-4">
|
||||
<div class="col-span-2 text-center md:col-span-1 md:text-left">
|
||||
<Badge class="border-0 md:pl-0" variant="outline">
|
||||
{l(data.lang, "Availability per Component")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="col-span-2 text-center md:col-span-1 md:text-right">
|
||||
<Badge variant="outline" class="border-0 md:pr-0">
|
||||
<span class="bg-api-up mr-1 inline-flex h-[8px] w-[8px] rounded-full opacity-75"></span>
|
||||
<span class="mr-3">
|
||||
{l(data.lang, "UP")}
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="bg-api-degraded mr-1 inline-flex h-[8px] w-[8px] rounded-full opacity-75"
|
||||
></span>
|
||||
<span class="mr-3">
|
||||
{l(data.lang, "DEGRADED")}
|
||||
</span>
|
||||
<span class="bg-api-degraded mr-1 inline-flex h-[8px] w-[8px] rounded-full opacity-75"></span>
|
||||
<span class="mr-3">
|
||||
{l(data.lang, "DEGRADED")}
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="bg-api-down mr-1 inline-flex h-[8px] w-[8px] rounded-full opacity-75"
|
||||
></span>
|
||||
<span class="">
|
||||
{l(data.lang, "DOWN")}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="z-20 mx-auto mb-8 flex w-full flex-1 flex-col items-start justify-center backdrop-blur-[2px] md:w-[655px]"
|
||||
>
|
||||
<Card.Root>
|
||||
<Card.Content class="monitors-card p-0">
|
||||
{#each data.monitors as monitor}
|
||||
<Monitor
|
||||
on:show_shareMenu={showShareMenu}
|
||||
{monitor}
|
||||
localTz={data.localTz}
|
||||
lang={data.lang}
|
||||
selectedLang={data.selectedLang}
|
||||
/>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</section>
|
||||
<span class="bg-api-down mr-1 inline-flex h-[8px] w-[8px] rounded-full opacity-75"></span>
|
||||
<span class="">
|
||||
{l(data.lang, "DOWN")}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="z-20 mx-auto mb-8 flex w-full flex-1 flex-col items-start justify-center backdrop-blur-[2px] md:w-[655px]"
|
||||
>
|
||||
<Card.Root>
|
||||
<Card.Content class="monitors-card p-0">
|
||||
{#each data.monitors as monitor}
|
||||
<Monitor
|
||||
on:show_shareMenu={showShareMenu}
|
||||
{monitor}
|
||||
localTz={data.localTz}
|
||||
lang={data.lang}
|
||||
selectedLang={data.selectedLang}
|
||||
/>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</section>
|
||||
{/if}
|
||||
{#if data.site.categories && isHome}
|
||||
<section
|
||||
class="relative z-10 mx-auto mb-8 w-full max-w-[890px] flex-1 flex-col items-start backdrop-blur-[2px] md:w-[655px]"
|
||||
>
|
||||
{#each data.site.categories.filter((e) => e.name != "Home") as category}
|
||||
<a
|
||||
href={`?category=${category.name}`}
|
||||
on:click={(e) => {
|
||||
e.preventDefault();
|
||||
window.location.href = `?category=${category.name}`;
|
||||
}}
|
||||
>
|
||||
<Card.Root class="mb-4 hover:bg-secondary">
|
||||
<Card.Header class="bounce-right relative w-full cursor-pointer px-4 ">
|
||||
<Card.Title class="w-full ">
|
||||
{category.name}
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="arrow absolute right-4 top-9 h-5 w-5 p-0 text-muted-foreground"
|
||||
size="icon"
|
||||
>
|
||||
<ArrowRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</Card.Title>
|
||||
<Card.Description>
|
||||
{#if category.description}
|
||||
{@html category.description}
|
||||
{/if}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
</Card.Root>
|
||||
</a>
|
||||
{/each}
|
||||
</section>
|
||||
{#if data.site.categories && data.pageType == "home"}
|
||||
<section
|
||||
class="relative z-10 mx-auto mb-8 w-full max-w-[890px] flex-1 flex-col items-start backdrop-blur-[2px] md:w-[655px]"
|
||||
>
|
||||
{#each data.site.categories.filter((e) => e.name != "Home") as category}
|
||||
<a href={`?category=${category.name}`} rel="external">
|
||||
<Card.Root class="mb-4 hover:bg-secondary">
|
||||
<Card.Header class="bounce-right relative w-full cursor-pointer px-4 ">
|
||||
<Card.Title class="w-full ">
|
||||
{category.name}
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="arrow absolute right-4 top-9 h-5 w-5 p-0 text-muted-foreground"
|
||||
size="icon"
|
||||
>
|
||||
<ArrowRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</Card.Title>
|
||||
<Card.Description>
|
||||
{#if category.description}
|
||||
{@html category.description}
|
||||
{/if}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
</Card.Root>
|
||||
</a>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
<section
|
||||
class="mx-auto mb-2 flex w-full max-w-[655px] flex-1 flex-col items-start justify-center bg-transparent"
|
||||
id=""
|
||||
>
|
||||
<a
|
||||
href="{base}/incidents/{format(new Date(), 'MMMM-yyyy')}"
|
||||
on:click={(e) => {
|
||||
e.preventDefault();
|
||||
window.location.href = `${base}/incidents/${format(new Date(), "MMMM-yyyy")}`;
|
||||
}}
|
||||
class="bounce-right grid w-full cursor-pointer grid-cols-2 justify-between gap-4 rounded-md border bg-card px-4 py-2 text-sm font-medium hover:bg-secondary"
|
||||
>
|
||||
<div class="col-span-1 text-left">
|
||||
{l(data.lang, "Browse Events")}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="arrow float-right mt-0.5">
|
||||
<ArrowRight class="h-4 w-4 text-muted-foreground hover:text-primary" />
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<section class="mx-auto mb-2 flex w-full max-w-[655px] flex-1 flex-col items-start justify-center bg-transparent" id="">
|
||||
<a
|
||||
href="{base}/incidents/{format(new Date(), 'MMMM-yyyy')}"
|
||||
rel="external"
|
||||
class="bounce-right grid w-full cursor-pointer grid-cols-2 justify-between gap-4 rounded-md border bg-card px-4 py-2 text-sm font-medium hover:bg-secondary"
|
||||
>
|
||||
<div class="col-span-1 text-left">
|
||||
{l(data.lang, "Browse Events")}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="arrow float-right mt-0.5">
|
||||
<ArrowRight class="h-4 w-4 text-muted-foreground hover:text-primary" />
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</section>
|
||||
{#if shareMenusToggle}
|
||||
<div
|
||||
transition:scale={{ duration: 100 }}
|
||||
use:hotKeyAction={{
|
||||
code: "Escape",
|
||||
cb: () => (shareMenusToggle = false)
|
||||
}}
|
||||
class="moldal-container fixed left-0 top-0 z-30 h-screen w-full bg-card bg-opacity-30 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
use:clickOutsideAction
|
||||
on:clickoutside={() => {
|
||||
shareMenusToggle = false;
|
||||
}}
|
||||
class="absolute left-1/2 top-1/2 h-fit w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-md border bg-background shadow-lg backdrop-blur-lg md:w-[568px]"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
on:click={() => {
|
||||
shareMenusToggle = false;
|
||||
}}
|
||||
class="absolute right-2 top-2 z-40 h-6 w-6 rounded-full border bg-background p-1"
|
||||
>
|
||||
<X class="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
<div class="content">
|
||||
<ShareMenu
|
||||
monitor={activeMonitor}
|
||||
lang={data.lang}
|
||||
selectedLang={data.selectedLang}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
transition:scale={{ duration: 100 }}
|
||||
use:hotKeyAction={{
|
||||
code: "Escape",
|
||||
cb: () => (shareMenusToggle = false)
|
||||
}}
|
||||
class="moldal-container fixed left-0 top-0 z-30 h-screen w-full bg-card bg-opacity-30 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
use:clickOutsideAction
|
||||
on:clickoutside={() => {
|
||||
shareMenusToggle = false;
|
||||
}}
|
||||
class="absolute left-1/2 top-1/2 h-fit w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-md border bg-background shadow-lg backdrop-blur-lg md:w-[568px]"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
on:click={() => {
|
||||
shareMenusToggle = false;
|
||||
}}
|
||||
class="absolute right-2 top-2 z-40 h-6 w-6 rounded-full border bg-background p-1"
|
||||
>
|
||||
<X class="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
<div class="content">
|
||||
<ShareMenu monitor={activeMonitor} lang={data.lang} selectedLang={data.selectedLang} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
import { json, redirect } from "@sveltejs/kit";
|
||||
import { base } from "$app/paths";
|
||||
import db from "$lib/server/db/db.js";
|
||||
import {
|
||||
HashPassword,
|
||||
GenerateSalt,
|
||||
GenerateToken,
|
||||
CookieConfig
|
||||
} from "$lib/server/controllers/controller.js";
|
||||
import { HashPassword, GenerateSalt, GenerateToken, CookieConfig } from "$lib/server/controllers/controller.js";
|
||||
|
||||
//function to validate a strong password
|
||||
/**
|
||||
@@ -22,50 +17,49 @@ import {
|
||||
* @returns {boolean} - Returns true if the password meets the criteria, otherwise false.
|
||||
*/
|
||||
function validatePassword(password) {
|
||||
return /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).{8,}$/.test(password);
|
||||
return /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).{8,}$/.test(password);
|
||||
}
|
||||
|
||||
export async function POST({ request, cookies }) {
|
||||
//read form post data email and passowrd
|
||||
const formdata = await request.formData();
|
||||
const email = formdata.get("email");
|
||||
const password = formdata.get("password");
|
||||
const name = formdata.get("name");
|
||||
//read form post data email and passowrd
|
||||
const formdata = await request.formData();
|
||||
const email = formdata.get("email");
|
||||
const password = formdata.get("password");
|
||||
const name = formdata.get("name");
|
||||
|
||||
//check if any entry in user table is already there
|
||||
let userCount = await db.getUsersCount();
|
||||
if (userCount.count != 0) {
|
||||
let errorMessage =
|
||||
"Set up already done. Please login with the email and password you have set up.";
|
||||
throw redirect(302, base + "/manage/setup?error=" + errorMessage);
|
||||
}
|
||||
//validate password
|
||||
if (!validatePassword(password)) {
|
||||
let errorMessage =
|
||||
"Password must contain at least one digit, one lowercase letter, one uppercase letter, and have a minimum length of 8 characters.";
|
||||
throw redirect(302, base + "/manage/setup?error=" + errorMessage);
|
||||
}
|
||||
let user = {
|
||||
email: email,
|
||||
password_hash: await HashPassword(password),
|
||||
name: name,
|
||||
role: "admin"
|
||||
};
|
||||
//check if any entry in user table is already there
|
||||
let userCount = await db.getUsersCount();
|
||||
if (userCount.count != 0) {
|
||||
let errorMessage = "Set up already done. Please login with the email and password you have set up.";
|
||||
throw redirect(302, base + "/manage/setup?error=" + errorMessage);
|
||||
}
|
||||
//validate password
|
||||
if (!validatePassword(password)) {
|
||||
let errorMessage =
|
||||
"Password must contain at least one digit, one lowercase letter, one uppercase letter, and have a minimum length of 8 characters.";
|
||||
throw redirect(302, base + "/manage/setup?error=" + errorMessage);
|
||||
}
|
||||
let user = {
|
||||
email: email,
|
||||
password_hash: await HashPassword(password),
|
||||
name: name,
|
||||
role: "admin",
|
||||
};
|
||||
|
||||
await db.insertUser(user);
|
||||
let userDB = await db.getUserByEmail(email);
|
||||
if (!!!userDB) {
|
||||
let errorMessage = "User does not exist";
|
||||
throw redirect(302, base + "/manage/signin?error=" + errorMessage);
|
||||
}
|
||||
let token = await GenerateToken(userDB);
|
||||
let cookieConfig = CookieConfig();
|
||||
cookies.set(cookieConfig.name, token, {
|
||||
path: cookieConfig.path,
|
||||
maxAge: cookieConfig.maxAge, // 1 year in seconds
|
||||
httpOnly: cookieConfig.httpOnly,
|
||||
secure: cookieConfig.secure,
|
||||
sameSite: cookieConfig.sameSite
|
||||
});
|
||||
throw redirect(302, base + "/");
|
||||
await db.insertUser(user);
|
||||
let userDB = await db.getUserByEmail(email);
|
||||
if (!!!userDB) {
|
||||
let errorMessage = "User does not exist";
|
||||
throw redirect(302, base + "/manage/signin?error=" + errorMessage);
|
||||
}
|
||||
let token = await GenerateToken(userDB);
|
||||
let cookieConfig = CookieConfig();
|
||||
cookies.set(cookieConfig.name, token, {
|
||||
path: cookieConfig.path,
|
||||
maxAge: cookieConfig.maxAge, // 1 year in seconds
|
||||
httpOnly: cookieConfig.httpOnly,
|
||||
secure: cookieConfig.secure,
|
||||
sameSite: cookieConfig.sameSite,
|
||||
});
|
||||
throw redirect(302, base + "/manage/app/site");
|
||||
}
|
||||
|
||||
@@ -88,20 +88,12 @@
|
||||
<h1 class="font-semibold">Manage Kener</h1>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
target="_blank"
|
||||
href="https://github.com/rajnandan1/kener"
|
||||
class="flex"
|
||||
>
|
||||
<Button variant="ghost" size="icon" target="_blank" href="https://github.com/rajnandan1/kener" class="flex">
|
||||
<Github class="h-4 w-4 " />
|
||||
</Button>
|
||||
<Button on:click={toggleMode} variant="ghost" size="icon" class="flex">
|
||||
<Sun class="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon
|
||||
class="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"
|
||||
/>
|
||||
<Moon class="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span class="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
<DropdownMenu.Root>
|
||||
@@ -126,15 +118,7 @@
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="ml-4"
|
||||
href="{base}/"
|
||||
on:click={(e) => {
|
||||
e.preventDefault();
|
||||
window.open(base + "/", "_blank");
|
||||
}}
|
||||
>
|
||||
<Button variant="secondary" class="ml-4" href="{base}/" rel="external">
|
||||
<Play class="mr-2 h-4 w-4" />
|
||||
<span>Preview</span>
|
||||
</Button>
|
||||
@@ -145,10 +129,8 @@
|
||||
<div class="container my-4 max-w-5xl">
|
||||
<nav class="flex justify-start gap-x-2">
|
||||
{#each nav as item}
|
||||
<Button
|
||||
variant={item.id.includes(activeTab) ? "secondary" : "ghost"}
|
||||
href={item.url}
|
||||
class="flex-1r">{item.name}</Button
|
||||
<Button variant={item.id.includes(activeTab) ? "secondary" : "ghost"} href={item.url} class="flex-1r"
|
||||
>{item.name}</Button
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
@@ -2,154 +2,153 @@
|
||||
// @ts-ignore
|
||||
import { json } from "@sveltejs/kit";
|
||||
import notification from "$lib/server/notification/notif.js";
|
||||
import Service from "$lib/server/services/service.js";
|
||||
import {
|
||||
CreateUpdateMonitor,
|
||||
InsertKeyValue,
|
||||
GetMonitors,
|
||||
CreateUpdateTrigger,
|
||||
GetAllTriggers,
|
||||
UpdateTriggerData,
|
||||
GetAllAlertsPaginated,
|
||||
GetAllAPIKeys,
|
||||
GetAllSiteData,
|
||||
CreateNewAPIKey,
|
||||
UpdateApiKeyStatus,
|
||||
VerifyToken,
|
||||
GetIncidentsDashboard,
|
||||
CreateIncident,
|
||||
AddIncidentMonitor,
|
||||
RemoveIncidentMonitor,
|
||||
GetIncidentActiveComments,
|
||||
UpdateCommentStatusByID,
|
||||
AddIncidentComment,
|
||||
UpdateCommentByID,
|
||||
UpdateIncident,
|
||||
GetTriggerByID
|
||||
CreateUpdateMonitor,
|
||||
InsertKeyValue,
|
||||
GetMonitors,
|
||||
CreateUpdateTrigger,
|
||||
GetAllTriggers,
|
||||
UpdateTriggerData,
|
||||
GetAllAlertsPaginated,
|
||||
GetIncidentByIDDashboard,
|
||||
GetMonitorsParsed,
|
||||
GetAllAPIKeys,
|
||||
GetAllSiteData,
|
||||
CreateNewAPIKey,
|
||||
UpdateApiKeyStatus,
|
||||
VerifyToken,
|
||||
GetIncidentsDashboard,
|
||||
CreateIncident,
|
||||
AddIncidentMonitor,
|
||||
RemoveIncidentMonitor,
|
||||
GetIncidentActiveComments,
|
||||
UpdateCommentStatusByID,
|
||||
AddIncidentComment,
|
||||
UpdateCommentByID,
|
||||
UpdateIncident,
|
||||
GetTriggerByID,
|
||||
} from "$lib/server/controllers/controller.js";
|
||||
|
||||
export async function POST({ request, cookies }) {
|
||||
const payload = await request.json();
|
||||
let action = payload.action;
|
||||
let data = payload.data || {};
|
||||
let resp = {};
|
||||
const payload = await request.json();
|
||||
let action = payload.action;
|
||||
let data = payload.data || {};
|
||||
let resp = {};
|
||||
|
||||
let tokenData = cookies.get("kener-user");
|
||||
let tokenData = cookies.get("kener-user");
|
||||
|
||||
if (!!!tokenData) {
|
||||
return json(
|
||||
{
|
||||
error: "Unauthorized"
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
if (!!!tokenData) {
|
||||
return json(
|
||||
{
|
||||
error: "Unauthorized",
|
||||
},
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
let tokenUser = await VerifyToken(tokenData);
|
||||
if (!!!tokenUser) {
|
||||
//redirect to signin page if user is not authenticated
|
||||
return json(
|
||||
{
|
||||
error: "Unauthorized"
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
let tokenUser = await VerifyToken(tokenData);
|
||||
if (!!!tokenUser) {
|
||||
//redirect to signin page if user is not authenticated
|
||||
return json(
|
||||
{
|
||||
error: "Unauthorized",
|
||||
},
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === "storeSiteData") {
|
||||
resp = await storeSiteData(data);
|
||||
} else if (action == "storeMonitorData") {
|
||||
resp = await CreateUpdateMonitor(data);
|
||||
} else if (action == "getMonitors") {
|
||||
resp = await GetMonitors(data);
|
||||
} else if (action == "createUpdateTrigger") {
|
||||
resp = await CreateUpdateTrigger(data);
|
||||
} else if (action == "getTriggers") {
|
||||
resp = await GetAllTriggers(data);
|
||||
} else if (action == "updateMonitorTriggers") {
|
||||
resp = await UpdateTriggerData(data);
|
||||
} else if (action == "getAllAlertsPaginated") {
|
||||
resp = await GetAllAlertsPaginated(data);
|
||||
} else if (action == "getAPIKeys") {
|
||||
resp = await GetAllAPIKeys();
|
||||
} else if (action == "createNewApiKey") {
|
||||
resp = await CreateNewAPIKey(data);
|
||||
} else if (action == "updateApiKeyStatus") {
|
||||
resp = await UpdateApiKeyStatus(data);
|
||||
} else if (action == "getIncidents") {
|
||||
resp = await GetIncidentsDashboard(data);
|
||||
} else if (action == "createIncident") {
|
||||
resp = await CreateIncident(data);
|
||||
} else if (action == "updateIncident") {
|
||||
resp = await UpdateIncident(data.id, data);
|
||||
} else if (action == "addMonitor") {
|
||||
resp = await AddIncidentMonitor(
|
||||
data.incident_id,
|
||||
data.monitor_tag,
|
||||
data.monitor_impact
|
||||
);
|
||||
} else if (action == "removeMonitor") {
|
||||
resp = await RemoveIncidentMonitor(data.incident_id, data.monitor_tag);
|
||||
} else if (action == "getComments") {
|
||||
resp = await GetIncidentActiveComments(data.incident_id);
|
||||
} else if (action == "addComment") {
|
||||
resp = await AddIncidentComment(
|
||||
data.incident_id,
|
||||
data.comment,
|
||||
data.state,
|
||||
data.commented_at
|
||||
);
|
||||
} else if (action == "deleteComment") {
|
||||
resp = await UpdateCommentStatusByID(data.incident_id, data.comment_id, "INACTIVE");
|
||||
} else if (action == "updateComment") {
|
||||
resp = await UpdateCommentByID(
|
||||
data.incident_id,
|
||||
data.comment_id,
|
||||
data.comment,
|
||||
data.state,
|
||||
data.commented_at
|
||||
);
|
||||
} else if (action == "testTrigger") {
|
||||
let trigger = await GetTriggerByID(data.trigger_id);
|
||||
let siteData = await GetAllSiteData();
|
||||
const notificationClient = new notification(trigger, siteData, {});
|
||||
const testObj = {
|
||||
id: "test",
|
||||
alert_name:
|
||||
"Test Alert " + (Math.floor(Math.random() * 100) % 2) == 0
|
||||
? "DOWN"
|
||||
: "DEGRADED",
|
||||
severity: Math.floor(Math.random() * 100) % 2 == 0 ? "critical" : "warning",
|
||||
status: Math.floor(Math.random() * 100) % 2 == 0 ? "TRIGGERED" : "RESOLVED",
|
||||
source: "Kener",
|
||||
timestamp: new Date().toISOString(),
|
||||
description: "Monitor has failed",
|
||||
details: {
|
||||
metric: "Test",
|
||||
current_value: Math.floor(Math.random() * 100),
|
||||
threshold: Math.floor(Math.random() * 100)
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
text: "View Monitor",
|
||||
url: siteData.siteURL + "/monitor-test"
|
||||
}
|
||||
]
|
||||
};
|
||||
resp = await notificationClient.send(testObj);
|
||||
}
|
||||
} catch (error) {
|
||||
resp = { error: error.message };
|
||||
return json(resp, { status: 500 });
|
||||
}
|
||||
return json(resp, { status: 200 });
|
||||
try {
|
||||
if (action === "storeSiteData") {
|
||||
resp = await storeSiteData(data);
|
||||
} else if (action == "storeMonitorData") {
|
||||
resp = await CreateUpdateMonitor(data);
|
||||
} else if (action == "getMonitors") {
|
||||
resp = await GetMonitors(data);
|
||||
} else if (action == "createUpdateTrigger") {
|
||||
resp = await CreateUpdateTrigger(data);
|
||||
} else if (action == "getTriggers") {
|
||||
resp = await GetAllTriggers(data);
|
||||
} else if (action == "updateMonitorTriggers") {
|
||||
resp = await UpdateTriggerData(data);
|
||||
} else if (action == "getAllAlertsPaginated") {
|
||||
resp = await GetAllAlertsPaginated(data);
|
||||
} else if (action == "getAPIKeys") {
|
||||
resp = await GetAllAPIKeys();
|
||||
} else if (action == "createNewApiKey") {
|
||||
resp = await CreateNewAPIKey(data);
|
||||
} else if (action == "updateApiKeyStatus") {
|
||||
resp = await UpdateApiKeyStatus(data);
|
||||
} else if (action == "getIncidents") {
|
||||
resp = await GetIncidentsDashboard(data);
|
||||
} else if (action == "getIncident") {
|
||||
resp = await GetIncidentByIDDashboard(data);
|
||||
if (!!!resp) {
|
||||
throw new Error("Incident not found");
|
||||
}
|
||||
} else if (action == "createIncident") {
|
||||
resp = await CreateIncident(data);
|
||||
} else if (action == "updateIncident") {
|
||||
resp = await UpdateIncident(data.id, data);
|
||||
} else if (action == "addMonitor") {
|
||||
resp = await AddIncidentMonitor(data.incident_id, data.monitor_tag, data.monitor_impact);
|
||||
} else if (action == "removeMonitor") {
|
||||
resp = await RemoveIncidentMonitor(data.incident_id, data.monitor_tag);
|
||||
} else if (action == "getComments") {
|
||||
resp = await GetIncidentActiveComments(data.incident_id);
|
||||
} else if (action == "addComment") {
|
||||
resp = await AddIncidentComment(data.incident_id, data.comment, data.state, data.commented_at);
|
||||
} else if (action == "deleteComment") {
|
||||
resp = await UpdateCommentStatusByID(data.incident_id, data.comment_id, "INACTIVE");
|
||||
} else if (action == "updateComment") {
|
||||
resp = await UpdateCommentByID(data.incident_id, data.comment_id, data.comment, data.state, data.commented_at);
|
||||
} else if (action == "testTrigger") {
|
||||
let trigger = await GetTriggerByID(data.trigger_id);
|
||||
let siteData = await GetAllSiteData();
|
||||
const notificationClient = new notification(trigger, siteData, {});
|
||||
const testObj = {
|
||||
id: "test",
|
||||
alert_name: "Test Alert " + (Math.floor(Math.random() * 100) % 2) == 0 ? "DOWN" : "DEGRADED",
|
||||
severity: Math.floor(Math.random() * 100) % 2 == 0 ? "critical" : "warning",
|
||||
status: Math.floor(Math.random() * 100) % 2 == 0 ? "TRIGGERED" : "RESOLVED",
|
||||
source: "Kener",
|
||||
timestamp: new Date().toISOString(),
|
||||
description: "Monitor has failed",
|
||||
details: {
|
||||
metric: "Test",
|
||||
current_value: Math.floor(Math.random() * 100),
|
||||
threshold: Math.floor(Math.random() * 100),
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
text: "View Monitor",
|
||||
url: siteData.siteURL + "/monitor-test",
|
||||
},
|
||||
],
|
||||
};
|
||||
resp = await notificationClient.send(testObj);
|
||||
} else if (action == "testMonitor") {
|
||||
let monitorID = data.monitor_id;
|
||||
let monitors = await GetMonitorsParsed({ id: monitorID });
|
||||
let monitor = monitors[0];
|
||||
if (monitor.monitor_type === "NONE") {
|
||||
throw new Error("Tests can't be run on monitor type NONE");
|
||||
}
|
||||
const serviceClient = new Service(monitor);
|
||||
resp = await serviceClient.execute();
|
||||
}
|
||||
} catch (error) {
|
||||
resp = { error: error.message };
|
||||
return json(resp, { status: 500 });
|
||||
}
|
||||
return json(resp, { status: 200 });
|
||||
}
|
||||
async function storeSiteData(data) {
|
||||
for (const key in data) {
|
||||
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
||||
const element = data[key];
|
||||
await InsertKeyValue(key, element);
|
||||
}
|
||||
}
|
||||
return { success: true };
|
||||
for (const key in data) {
|
||||
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
||||
const element = data[key];
|
||||
await InsertKeyValue(key, element);
|
||||
}
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -62,19 +62,15 @@
|
||||
})
|
||||
});
|
||||
let resp = await apiResp.json();
|
||||
|
||||
incidents = resp.incidents.map((incident) => {
|
||||
let i = { ...incident };
|
||||
if (!!!i.end_date_time) {
|
||||
i.duration = moment
|
||||
.duration(
|
||||
parseInt(new Date().getTime() / 1000) - parseInt(i.start_date_time),
|
||||
"seconds"
|
||||
)
|
||||
.duration(parseInt(new Date().getTime() / 1000) - parseInt(i.start_date_time), "seconds")
|
||||
.humanize();
|
||||
} else {
|
||||
i.duration = moment
|
||||
.duration(parseInt(i.end_date_time) - parseInt(i.start_date_time), "seconds")
|
||||
.humanize();
|
||||
i.duration = moment.duration(parseInt(i.end_date_time) - parseInt(i.start_date_time), "seconds").humanize();
|
||||
}
|
||||
return i;
|
||||
});
|
||||
@@ -109,11 +105,40 @@
|
||||
let isMounted = false;
|
||||
|
||||
//find by id and open modal
|
||||
function findEventByIdAndOpenModal(id) {
|
||||
async function findEventByIdAndOpenModal(id) {
|
||||
id = parseInt(id);
|
||||
if (isNaN(id)) {
|
||||
return;
|
||||
}
|
||||
let incident = incidents.find((i) => i.id == id);
|
||||
if (!!incident) {
|
||||
openIncidentSettings(incident);
|
||||
} else {
|
||||
loadingData = true;
|
||||
|
||||
try {
|
||||
let resp = await fetch(base + "/manage/app/api/", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: "getIncident",
|
||||
data: {
|
||||
incident_id: id
|
||||
}
|
||||
})
|
||||
});
|
||||
let data = await resp.json();
|
||||
if (!!data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
openIncidentSettings(data);
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
} finally {
|
||||
loadingData = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,6 +418,11 @@
|
||||
});
|
||||
window.dispatchEvent(noScrollEvent);
|
||||
}
|
||||
//if modal closed then clear url hashed
|
||||
}
|
||||
function closeModal() {
|
||||
showModal = false;
|
||||
window.location.hash = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -419,9 +449,7 @@
|
||||
<Select.Label>Status</Select.Label>
|
||||
<Select.Item value="ALL" label="ALL" class="text-sm font-medium">ALL</Select.Item>
|
||||
<Select.Item value="OPEN" label="OPEN" class="text-sm font-medium">ACTIVE</Select.Item>
|
||||
<Select.Item value="CLOSED" label="CLOSED" class="text-sm font-medium">
|
||||
DELETED
|
||||
</Select.Item>
|
||||
<Select.Item value="CLOSED" label="CLOSED" class="text-sm font-medium">DELETED</Select.Item>
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
@@ -537,9 +565,7 @@
|
||||
</td>
|
||||
<td class=" px-6 py-4 text-xs font-semibold">
|
||||
<Tooltip.Root openDelay={100}>
|
||||
<Tooltip.Trigger
|
||||
class="max-w-[12rem] overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
<Tooltip.Trigger class="max-w-[12rem] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{incident.title}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
@@ -562,14 +588,10 @@
|
||||
class=" flex items-center justify-center rounded border bg-card p-1.5 text-xs font-medium shadow-popover outline-none"
|
||||
>
|
||||
<span class="mr-1 inline-block text-muted-foreground">From</span>
|
||||
{moment(Number(incident.start_date_time) * 1000).format(
|
||||
"YYYY-MM-DD HH:mm:ss"
|
||||
)}
|
||||
{moment(Number(incident.start_date_time) * 1000).format("YYYY-MM-DD HH:mm:ss")}
|
||||
<span class="mx-1 inline-block text-muted-foreground">To</span>
|
||||
{#if incident.end_date_time}
|
||||
{moment(Number(incident.end_date_time) * 1000).format(
|
||||
"YYYY-MM-DD HH:mm:ss"
|
||||
)}
|
||||
{moment(Number(incident.end_date_time) * 1000).format("YYYY-MM-DD HH:mm:ss")}
|
||||
{:else}
|
||||
Now
|
||||
{/if}
|
||||
@@ -611,6 +633,7 @@
|
||||
variant="ghost"
|
||||
class="h-5 w-5 p-1"
|
||||
size="icon"
|
||||
disabled={incident.isAutoCreated}
|
||||
on:click={(e) => {
|
||||
showEditModal(incident);
|
||||
}}
|
||||
@@ -640,9 +663,7 @@
|
||||
<div
|
||||
transition:slide={{ direction: "right", duration: 200 }}
|
||||
use:clickOutsideAction
|
||||
on:clickoutside={(e) => {
|
||||
showModal = false;
|
||||
}}
|
||||
on:clickoutside={closeModal}
|
||||
class="absolute right-0 top-0 h-screen w-[800px] border-l bg-background p-4 shadow-xl"
|
||||
>
|
||||
<div class="mt-0 w-full overflow-y-auto p-3" style="height: 100vh;">
|
||||
@@ -695,11 +716,7 @@
|
||||
</span>
|
||||
{/if}
|
||||
</Label>
|
||||
<Input
|
||||
class="mt-2"
|
||||
bind:value={newIncident.title}
|
||||
placeholder="Outage in all servers"
|
||||
/>
|
||||
<Input class="mt-2" bind:value={newIncident.title} placeholder="Outage in all servers" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -887,10 +904,8 @@
|
||||
<div class="mt-4 border-t">
|
||||
{#each comments as comment}
|
||||
<div class="flex items-center justify-between gap-2 border-b py-2">
|
||||
<div
|
||||
class="w-full rounded px-2 py-2 {newComment.id == comment.id ? 'bg-input' : ''}"
|
||||
>
|
||||
<p class="mb-2 text-xs font-medium">{comment.comment}</p>
|
||||
<div class="w-full rounded px-2 py-2 {newComment.id == comment.id ? 'bg-input' : ''}">
|
||||
<p class="mb-2 text-xs font-medium">{@html comment.comment}</p>
|
||||
<div class="flex w-full justify-between gap-x-2">
|
||||
<div class="text-xs font-semibold text-muted-foreground">
|
||||
{#if currentIncident.incident_type == "INCIDENT"}
|
||||
@@ -911,12 +926,7 @@
|
||||
>
|
||||
<PenLine class="inline h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 p-1"
|
||||
on:click={() => deleteComment(comment)}
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 p-1" on:click={() => deleteComment(comment)}>
|
||||
<Trash class="inline h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -966,9 +976,7 @@
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="absolute right-[785px] top-7 h-8 w-8 rounded-md"
|
||||
on:click={(e) => {
|
||||
showModal = false;
|
||||
}}
|
||||
on:click={closeModal}
|
||||
>
|
||||
<ChevronRight class="h-6 w-6 " />
|
||||
</Button>
|
||||
@@ -977,9 +985,7 @@
|
||||
{/if}
|
||||
|
||||
{#if editMonitorsModal}
|
||||
<div
|
||||
class="moldal-container fixed left-0 top-0 z-50 h-screen w-full bg-card bg-opacity-30 backdrop-blur-sm"
|
||||
>
|
||||
<div class="moldal-container fixed left-0 top-0 z-50 h-screen w-full bg-card bg-opacity-30 backdrop-blur-sm">
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-fit w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-md border bg-background shadow-lg backdrop-blur-lg"
|
||||
>
|
||||
@@ -1022,26 +1028,15 @@
|
||||
}}
|
||||
>
|
||||
<Select.Trigger id="impact-{monitor.tag}">
|
||||
<Select.Value
|
||||
bind:value={monitor.monitor_impact}
|
||||
placeholder={monitor.monitor_impact}
|
||||
/>
|
||||
<Select.Value bind:value={monitor.monitor_impact} placeholder={monitor.monitor_impact} />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.Label>Impact</Select.Label>
|
||||
<Select.Item
|
||||
value="DOWN"
|
||||
label="DOWN"
|
||||
class="text-api-down text-sm font-medium"
|
||||
>
|
||||
<Select.Item value="DOWN" label="DOWN" class="text-api-down text-sm font-medium">
|
||||
DOWN
|
||||
</Select.Item>
|
||||
<Select.Item
|
||||
value="DEGRADED"
|
||||
label="DEGRADED"
|
||||
class="text-api-degraded text-sm font-medium"
|
||||
>
|
||||
<Select.Item value="DEGRADED" label="DEGRADED" class="text-api-degraded text-sm font-medium">
|
||||
DEGRADED
|
||||
</Select.Item>
|
||||
</Select.Group>
|
||||
|
||||
|
Before Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 670 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 168 B After Width: | Height: | Size: 168 B |