mirror of
https://github.com/rajnandan1/kener.git
synced 2026-08-07 15:27:13 +00:00
Compare commits
217 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| ba2fe24629 | |||
| 547116090a | |||
| 974826f42d | |||
| 4914b029f9 | |||
| ff864fbaab | |||
| f583ba4938 | |||
| d552f541ac | |||
| d46d02e37a | |||
| 4e0c6e85da | |||
| 1147808366 | |||
| a7c27a60e0 | |||
| 974976bd90 | |||
| 9bbe665984 | |||
| d0ea8551b6 | |||
| 089ee9bc07 | |||
| 786c4f8207 | |||
| 13879aefdd | |||
| d03a8fd7a4 | |||
| c00aae5566 | |||
| e923f4d650 | |||
| 654c07a364 | |||
| 820eeb0aac | |||
| 2a338baa26 | |||
| ec7351272f | |||
| 13dec43ef3 | |||
| 43673e3349 | |||
| 1e77253a63 | |||
| 798af326a2 | |||
| d349a7591e | |||
| 8370145f6c | |||
| 304945acea | |||
| 1dd05fee50 | |||
| 374eda4103 | |||
| 52a8cca3e8 | |||
| 22ef4d75b6 | |||
| 534e6a0ad3 | |||
| 80ed4fc5fa | |||
| 445eb02386 | |||
| 53ae0b89b0 | |||
| c7376b4d8f | |||
| e3e59b7e24 | |||
| 07f59ac581 | |||
| 7176c3d4f4 | |||
| 13366284c6 | |||
| 0f0b447137 | |||
| f7cc28c896 | |||
| fd790003d1 | |||
| fdad329148 | |||
| 73bf5f3fbe | |||
| 103d64a659 | |||
| 92c4d35992 | |||
| 3b1d95b71b | |||
| 4fd9bf2bb6 | |||
| ce96b6f55d | |||
| 54bbc1dd00 | |||
| ffa31bcacc | |||
| 559f5bd257 | |||
| 977e49e1ce | |||
| 30cb707436 | |||
| ae439633b9 | |||
| 7f33f6ddfd | |||
| 8404415a93 | |||
| 5ddddf8b5d | |||
| 927db19cc6 | |||
| eccff16c5f | |||
| 7be9c62c7c | |||
| 76ce14e8b1 | |||
| fd0074c0b0 | |||
| ad01cdb5ac | |||
| 1b4ca67cf0 | |||
| 53936e19d4 | |||
| b58d00c5a6 | |||
| 41db0c45cd | |||
| 5361b551eb | |||
| eb87647466 | |||
| d9b900f28d | |||
| 198bd6c723 | |||
| 1831e6309f | |||
| 007f5149d2 | |||
| 1a094e3511 | |||
| db1ddc1292 | |||
| 535e3bb36a | |||
| 785237e55e | |||
| e6bf47a859 | |||
| 93ae1711f1 | |||
| af97812beb | |||
| 78cf22ade9 | |||
| b4a461d93a | |||
| 5f33c02064 | |||
| 46c1a392b8 | |||
| c6880c5df6 | |||
| 646da94ef9 | |||
| 36ede93dce | |||
| 9ed35589f7 | |||
| e946dd18e0 | |||
| 51cad598c1 | |||
| 0595f23c18 | |||
| b88816706b | |||
| 80edaae023 | |||
| 883c46b064 | |||
| 4248cc31f2 | |||
| 5f81c6d61a | |||
| 00d82cc817 | |||
| a9edfde162 | |||
| 468d7f445d | |||
| 4cc74deece | |||
| f9831490af | |||
| a055a616eb | |||
| 8009a2cbc4 | |||
| 693cc149cf | |||
| d126fe0bce | |||
| 4cc49cce9e | |||
| f7cb4cd805 | |||
| a2bd9883d1 | |||
| 10a079c304 | |||
| 65f12f1082 | |||
| c15f855c72 | |||
| 3d38483022 | |||
| 57dc1e7175 | |||
| ead68c929f | |||
| d54b346259 | |||
| a05de8269c | |||
| 130b70c6af | |||
| 7247a54c08 | |||
| 28de7ebff8 | |||
| e2c9d4dc2d | |||
| 4866382180 | |||
| 1b1507db1a | |||
| 0ea7d687a7 | |||
| 502e1539ca | |||
| f3910fea1b | |||
| ee9b48fb79 | |||
| 142686ef92 | |||
| 2aa187984e | |||
| c20d4d71c3 | |||
| 4f4d1e1b39 | |||
| f38b590f70 | |||
| c2caf42fd3 | |||
| 8c279c8c10 | |||
| 3480c78360 | |||
| 79ce708f60 | |||
| a4526eb4b5 | |||
| 866a133917 | |||
| adce978244 | |||
| dd3c26b29a | |||
| 25ad42ba9b | |||
| 3285b7e472 | |||
| c7da55480a | |||
| 35e81b5782 | |||
| 769a9526e7 | |||
| 5470153f4f | |||
| 9d6ad87275 | |||
| ac6261d9ac | |||
| 48effe077e | |||
| c656bf8612 | |||
| 9daf7c7156 | |||
| 4bd3bfae9d | |||
| 727964949c | |||
| 21e1fc1aca |
@@ -1,4 +1,7 @@
|
||||
node_modules
|
||||
.git
|
||||
.github
|
||||
.vscode
|
||||
dist
|
||||
build
|
||||
.env
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# EditorConfig helps maintain consistent coding styles between editors
|
||||
root = true
|
||||
|
||||
# Default settings for all files (e.g. most common, best-practice standard across all filetypes)
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
# Svelte files
|
||||
[*.svelte]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
# JavaScript and TypeScript
|
||||
[*.{js,ts,tsx,cjs,mjs}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
# JSON files (package.json, config files, etc.) - per JSON (RFC 8259) specification
|
||||
[*.json,.prettierrc]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
# YAML files (e.g., GitHub Actions, Lint configs) - per YAML 1.2 (2009) specification
|
||||
[*.{yaml,yml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
# Markdown files
|
||||
[*.md]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
# Dockerfile
|
||||
[Dockerfile*]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
insert_final_newline = false
|
||||
|
||||
# Ignore binary files
|
||||
[*.{png,jpg,jpeg,gif,ico,svg,woff,woff2,eot,ttf,otf}]
|
||||
charset = unset
|
||||
trim_trailing_whitespace = false
|
||||
insert_final_newline = false
|
||||
+23
-9
@@ -1,12 +1,26 @@
|
||||
KENER_SECRET_KEY=please_change_me
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
GH_TOKEN=your_github_token
|
||||
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
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,48 @@
|
||||
# Contributing to Kener
|
||||
|
||||
Thank you for considering contributing to our project! Here are some guidelines to help you get started.
|
||||
|
||||
---
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. Fork the repository and clone it locally.
|
||||
2. Create a new branch for your feature or bug fix:
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
3. Make your changes and commit them:
|
||||
```bash
|
||||
git commit -m 'Describe your changes'
|
||||
```
|
||||
4. Push your changes to your fork:
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
5. Create a pull request to the `main` branch.
|
||||
|
||||
|
||||
## Development
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
2. Create a `.env` file in the root of the project and add the following:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
2. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
3. Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||
|
||||
## Documentation
|
||||
|
||||
The documentation is available in the `docs` folder. You can view it by going to [http://localhost:3000/docs/home](http://localhost:3000/docs/home) in your browser.
|
||||
|
||||
## Where to Start
|
||||
|
||||
1. Check out the [roadmap items](https://kener.ing/docs/roadmap/)
|
||||
2. Add language support by following the [i18n guide](https://kener.ing/docs/i18n/)
|
||||
@@ -17,22 +17,17 @@ Steps to reproduce the behavior:
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Version**
|
||||
Which version of kener you are using.
|
||||
|
||||
**Environment**
|
||||
Which environment you are using or where is it deployed. `docker`, `kubernetes`, `bare-metal`, `development`, `pm2` etc
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
@@ -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
|
||||
+4
-1
@@ -21,7 +21,10 @@ db/*
|
||||
!db/.kener
|
||||
database/*
|
||||
!database/.kener
|
||||
uploads/*
|
||||
!uploads/upload.dir
|
||||
|
||||
static/uploads/*
|
||||
!static/uploads/upload.dir
|
||||
temp.txt
|
||||
temp.txt
|
||||
temp.js
|
||||
+69
-7
@@ -1,9 +1,71 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"semi": true,
|
||||
"tabWidth": 4,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte",
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 120
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.ts", "*.tsx", "*.cjs", "*.mjs"],
|
||||
"options": {
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 120
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["*.json", ".prettierrc"],
|
||||
"options": {
|
||||
"useTabs": false,
|
||||
"semi": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 120
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["*.yaml", "*.yml"],
|
||||
"options": {
|
||||
"useTabs": false,
|
||||
"semi": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": "*.md",
|
||||
"options": {
|
||||
"useTabs": false,
|
||||
"semi": false,
|
||||
"tabWidth": 4,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": "Dockerfile",
|
||||
"options": {
|
||||
"useTabs": true,
|
||||
"tabWidth": 4,
|
||||
"semi": false,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 120
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+131
-20
@@ -1,35 +1,146 @@
|
||||
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 && \
|
||||
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
|
||||
ENV TZ=Etc/UTC
|
||||
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=2024b-r1 \
|
||||
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 . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
# TODO: Reevaluate permissions (possibly reduce?)...
|
||||
# Remove docs directory and ensure required directories exist
|
||||
RUN rm -rf src/routes/\(docs\) && \
|
||||
mkdir -p uploads database && \
|
||||
# TODO: Consider changing below to `chmod -R u-rwX,g=rX,o= uploads database`
|
||||
chmod -R 750 uploads database
|
||||
|
||||
# Build the application and remove `devDependencies`
|
||||
RUN npm run build && \
|
||||
npm prune --omit=dev
|
||||
|
||||
# Set the command to run the application
|
||||
#==========================================================#
|
||||
# STAGE 2: PRODUCTION/FINAL STAGE #
|
||||
#==========================================================#
|
||||
|
||||
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=2024b-r1
|
||||
|
||||
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
|
||||
|
||||
# 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"]
|
||||
@@ -1,4 +1,4 @@
|
||||
# Kener - A Beautiful Status Page System
|
||||
# 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">
|
||||
@@ -7,9 +7,27 @@
|
||||
<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>
|
||||
</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">
|
||||
@@ -25,94 +43,175 @@
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
#### [👉 Visit a live server](https://kener.ing)
|
||||
|
||||
#### [👉 Quick Start](https://kener.ing/docs/quick-start)
|
||||
|
||||
#### [👉 Documentation](https://kener.ing/docs/home)
|
||||
| [🌍 Live Server](https://kener.ing) | [🎉 Quick Start](https://kener.ing/docs/quick-start) | [🗄 Documentation](https://kener.ing/docs/home) |
|
||||
| ----------------------------------- | ---------------------------------------------------- | ----------------------------------------------- |
|
||||
|
||||
## 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
|
||||
|
||||
### 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=3.1.0"><img src="https://img.shields.io/badge/Latest_Stable_Release-3.1.0-blue" alt="Kener latest stable version: 3.1.0" /></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.0)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.1.0" target="_blank"><code>3.1.0</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.0) 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.0) 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.0)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.1.0-alpine" target="_blank"><code>3.1.0-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.0) 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.0) 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
|
||||
|
||||
[](https://railway.com/template/spSvic?referralCode=1Pn7vs)
|
||||
|
||||
<div align="left">
|
||||
<img alt="Visitor Stats" src="https://widgetbite.com/stats/rajnandan"/>
|
||||
</div>
|
||||
|
||||
## 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
|
||||
### 📊 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
|
||||
- Handles Timezones for visitors
|
||||
- Categorize Monitors into different Sections
|
||||
- Cron-based scheduling for monitors. Minimum per minute
|
||||
- Flexible monitor configuration using YAML
|
||||
- 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
|
||||
- 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
|
||||
- **Incident tracking & communication** tools
|
||||
- **Comprehensive APIs** for incident management
|
||||
|
||||
### User Experience and Design
|
||||
### 🧑💻 User Experience and Design
|
||||
|
||||
- 100% Accessibility Score
|
||||
- Easy installation and setup
|
||||
- User-friendly interface
|
||||
- Responsive design for various devices
|
||||
- Auto SEO and Social Media ready
|
||||
- **Accessible & user-friendly interface**
|
||||
- **Quick & easy installation**
|
||||
- **Responsive design** for all devices
|
||||
- **Auto SEO & Social Media ready**
|
||||
- **Server-Side Rendering (SSR) for better performance**
|
||||
|
||||
## Technologies used
|
||||
<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/)
|
||||
|
||||
## Inspired from
|
||||
|
||||
- [Upptime](https://upptime.js.org/)
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 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 [Contribution Guide](https://github.com/rajnandan1/kener/blob/main/.github/CONTRIBUTING.md).
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#rajnandan1/kener&Date)
|
||||
|
||||
@@ -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)
|
||||
+51
-18
@@ -1,28 +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
|
||||
#- GH_TOKEN=
|
||||
#- PORT=3000
|
||||
#- KENER_BASE_PATH=
|
||||
#- RESEND_API_KEY=
|
||||
#- ORIGIN=
|
||||
#- KENER_SECRET_KEY=
|
||||
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:
|
||||
|
||||
# If running on a LINUX HOST and not podman rootless these MUST BE SET
|
||||
# run "id $user" from command line and replace numbers below with output from command
|
||||
#- PUID=1000 # gid
|
||||
#- PGID=1000 # uid
|
||||
### 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'
|
||||
- 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
|
||||
@@ -5,6 +5,61 @@ description: Changelogs for Kener
|
||||
|
||||
# Changelogs
|
||||
|
||||
Here are the changelogs for Kener. Changelogs are only published when there are new features or breaking changes.
|
||||
|
||||
## v3.0.10
|
||||
|
||||
<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>
|
||||
|
||||
### Features
|
||||
|
||||
- Added TCP monitors
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Ping monitors will break.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Bug fixes in the UI
|
||||
|
||||
## v3.0.9
|
||||
|
||||
<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>
|
||||
|
||||
### Features
|
||||
|
||||
- Support of SMTP for email notifications. Read more [here](/docs/triggers/#email-smtp)
|
||||
- Introduction of event type `MAINTENANCE` for incidents.
|
||||
- You can write eval function for ping now in monitors. Read more [here](/docs/monitors-ping/#eval)
|
||||
- Added category filter for monitor management.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Support longer TLD in `siteURL` example `https://example.network`
|
||||
- Remove googleapis preconnect and preload
|
||||
- Fixed wrong action url in webhook.
|
||||
|
||||
## v3.0.1
|
||||
|
||||
<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>
|
||||
|
||||
Here are the changes in this release
|
||||
|
||||
### Features
|
||||
|
||||
- Support for i18n in dates.
|
||||
- Support of i18n in monitor embeds. Read more [here](/docs/embed#javascript-parameters)
|
||||
|
||||
## v3.0.0
|
||||
|
||||
<picture>
|
||||
|
||||
@@ -27,6 +27,10 @@ DNS Monitor is used to check the status of a DNS server. It can be configured to
|
||||
|
||||
Ping Monitor is used to check the status of a server. It can be configured to check the status of the server at different intervals.
|
||||
|
||||
### Data Interpolation
|
||||
|
||||
Kener interpolates the data for missing intervals. For example, suppose if you have a cron monitor of 5 minutes and it has run twice at 10AM and 10:05AM and the monitor is `DOWN` at 10AM. Kener will interpolate the data for 10:01AM, 10:02AM, 10:03AM and 10:04AM as `DOWN`. Similarly, if the monitor is UP at 10:05AM, Kener will interpolate the data for 10:06AM, 10:07AM, 10:08AM and 10:09AM as UP. Similarly for missing data points it will consider the previous data point. If there is no previous data point, it will consider it as `UP`.
|
||||
|
||||
---
|
||||
|
||||
## Triggers
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: How to Add Custom Fonts | Kener
|
||||
description: Kener allows you to add custom fonts to your site. This guide will help you add custom fonts to your Kener site.
|
||||
---
|
||||
|
||||
# Add Custom Fonts
|
||||
|
||||
1. Login to your Kener dashboard.
|
||||
2. Go to the `Theme` page.
|
||||
3. Scroll down to the `Fonts` section.
|
||||
4. Remove `Font URL` and `Font Name`
|
||||
5. In Custom CSS add your font
|
||||
|
||||
```css
|
||||
@font-face {
|
||||
font-family: "CustomFont";
|
||||
src:
|
||||
url("/path-to-font/CustomFont.woff2") format("woff2"),
|
||||
url("/path-to-font/CustomFont.woff") format("woff"),
|
||||
url("/path-to-font/CustomFont.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
* {
|
||||
font-family: "CustomFont", sans-serif;
|
||||
}
|
||||
```
|
||||
@@ -15,6 +15,10 @@ Add your custom JS to `static/` file. And in the `src/app.html` file, add the fo
|
||||
|
||||
## Adding Custom CSS
|
||||
|
||||
Three are two ways you can add custom CSS to your Kener instance.
|
||||
|
||||
### CSS file
|
||||
|
||||
Add your custom CSS to `static/` file. And in the `src/app.html` file, add the following line:
|
||||
|
||||
```html
|
||||
@@ -22,3 +26,17 @@ Add your custom CSS to `static/` file. And in the `src/app.html` file, add the f
|
||||
```
|
||||
|
||||
Do not forget to add the base path if you are using a subpath. For example, if you are using a subpath `/kener`, then the path should be `/kener/your-custom-js-file.js`.
|
||||
|
||||
### Inline CSS
|
||||
|
||||
To add inline css go to Manage kener -> Theme -> Custom CSS and add your CSS there.
|
||||
|
||||
```css
|
||||
.my-class {
|
||||
color: red;
|
||||
}
|
||||
```
|
||||
|
||||
<div class="note danger">
|
||||
Do not include <style> tags.
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,7 @@ i18n:
|
||||
vi: "Tiếng Việt"
|
||||
pattern: "squares"
|
||||
font:
|
||||
cssSrc: "https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap"
|
||||
cssSrc: "https://fonts.bunny.net/css?family=lato:100,100i,300,300i,400,400i,700,700i,900,900i&display=swap"
|
||||
family: '"Lato", sans-serif'
|
||||
analytics:
|
||||
- id: "G-QsFT"
|
||||
@@ -315,11 +315,11 @@ You can set the background pattern of the site. It can be `squares`, `dots`, `no
|
||||
|
||||
## font
|
||||
|
||||
You can set the font of the site. You can use google fonts or any other font.
|
||||
You can set the font of the site. You can use google fonts, [bunny fonts](https://fonts.bunny.net/) (which is a fully GDPR-compliant, zero-tracking/no-logging CDN font service), or any other font.
|
||||
|
||||
```yaml
|
||||
font:
|
||||
cssSrc: "https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap"
|
||||
cssSrc: "https://fonts.bunny.net/css?family=lato:100,100i,300,300i,400,400i,700,700i,900,900i&display=swap"
|
||||
family: '"Lato", sans-serif'
|
||||
```
|
||||
|
||||
|
||||
+80
-7
@@ -47,27 +47,49 @@ docker.io/rajnandan1/kener:latest
|
||||
ghcr.io/rajnandan1/kener:latest
|
||||
```
|
||||
|
||||
You should mount two host directories to persist your configuration and database. [Environmental variables](/docs/environment-vars) can be passed with `-e` An example `docker run` command:
|
||||
### Volumes
|
||||
|
||||
Make sure `./database` directory is present in the root directory.
|
||||
- `$(pwd)/uploads` You should mount one host directories to persist your image uploads.
|
||||
- `$(pwd)/database` If you are using sqlite, you should mount one host directory to persist your database.
|
||||
|
||||
This example is for sqlite. You can also use postgres. Read more about it [here](/docs/environment-vars/#database-url)
|
||||
### Environment Variables
|
||||
|
||||
[Environment variables](/docs/environment-vars) can be passed with `-e` An example `docker run` command:
|
||||
|
||||
Make sure `./database` and `./uploads` directories are present in the root directory.
|
||||
|
||||
### Examples
|
||||
|
||||
This example is for sqlite. You can also use postgres. Read more about it [here](/docs/environment-vars#database-url)
|
||||
|
||||
#### sqlite
|
||||
|
||||
```bash
|
||||
mkdir database uploads
|
||||
```
|
||||
|
||||
```bash
|
||||
mkdir database
|
||||
docker run \
|
||||
-v $(pwd)/database:/app/database \
|
||||
-v $(pwd)/uploads:/app/uploads \
|
||||
-p 3000:3000 \
|
||||
-e "KENER_SECRET_KEY=somesecretkey" \
|
||||
-e "ORIGIN=http://localhost:3000" \
|
||||
rajnandan1/kener
|
||||
```
|
||||
|
||||
#### .env
|
||||
|
||||
You can also use a .env file
|
||||
|
||||
```bash
|
||||
mkdir database uploads
|
||||
```
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-v $(pwd)/database:/app/database \
|
||||
-v $(pwd)/uploads:/app/uploads \
|
||||
--env-file .env \
|
||||
-p 3000:3000 \
|
||||
rajnandan1/kener
|
||||
@@ -75,15 +97,66 @@ docker run \
|
||||
|
||||
Or use **Docker Compose** with the example [docker-compose.yaml](https://raw.githubusercontent.com/rajnandan1/kener/main/docker-compose.yml)
|
||||
|
||||
## Base path
|
||||
#### Postgres
|
||||
|
||||
By default kener runs on `/` but you can change it to `/status` or any other path. Read more about it [here](/docs/environment-vars/#kener-base-path)
|
||||
```bash
|
||||
mkdir uploads
|
||||
```
|
||||
|
||||
## Postgres
|
||||
Database folder is not required for postgres.
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/uploads:/app/uploads \
|
||||
-e "KENER_SECRET_KEY=somesecretkey" \
|
||||
-e "DATABASE_URL=postgresql://myuser:mypassword@localhost:5432/mydatabase" \
|
||||
-e "ORIGIN=http://localhost:3000" \
|
||||
rajnandan1/kener
|
||||
```
|
||||
|
||||
#### MySQL
|
||||
|
||||
```bash
|
||||
mkdir uploads
|
||||
```
|
||||
|
||||
Database folder is not required for mysql.
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/uploads:/app/uploads \
|
||||
-e "KENER_SECRET_KEY=somesecretkey" \
|
||||
-e "DATABASE_URL=mysql://root:password@mysql-container-2.orb.local:3306/kener-2" \
|
||||
-e "ORIGIN=http://localhost:3000" \
|
||||
rajnandan1/kener
|
||||
```
|
||||
|
||||
#### Base path
|
||||
|
||||
By default kener runs on `/` but you can change it to `/status` or any other path. Read more about it [here](/docs/environment-vars/#kener-base-path).
|
||||
|
||||
<div class="note info">
|
||||
|
||||
- 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>
|
||||
|
||||
Let us say you are running kener on a subpath `/status`. You can set the base path like this:
|
||||
|
||||
```bash
|
||||
docker build --build-arg KENER_BASE_PATH=/status -t kener .
|
||||
```
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 --env-file .env -v $(pwd)/database:/app/uploads -v $(pwd)/database:/app/database kener
|
||||
```
|
||||
|
||||
## Railway
|
||||
|
||||
You can deploy Kener on [Railway](https://railway.app) with a single click.
|
||||
|
||||
[](https://railway.com/template/spSvic?referralCode=1Pn7vs)
|
||||
|
||||
+4
-2
@@ -33,7 +33,8 @@ You can pass the following parameters to the embed code
|
||||
|
||||
- `theme`: You can pass `light` or `dark` theme
|
||||
- `monitor`: The monitor url
|
||||
- `bgc`: Background color of the monitor. Only supports hex color codes. DO NOT include the `#` symbol
|
||||
- `bgc`: Background color of the monitor. Only supports hex color codes. DO NOT include the `#` symbol. Example: `ff0000`
|
||||
- `locale`: The locale of the monitor. You can pass the code of the locale you have enabled in your kener settings. Example: `en`, `fr` etc
|
||||
|
||||
Replace `[hostname]` with your kener hostname and `[tag]` with your monitor tag.
|
||||
|
||||
@@ -78,7 +79,8 @@ Replace `[hostname]` with your kener hostname and `[tag]` with your monitor tag.
|
||||
You can pass the following parameters to the embed code
|
||||
|
||||
- `theme`: You can pass `light` or `dark` theme
|
||||
- `bgc`: Background color of the monitor. Only supports hex color codes. DO NOT include the `#` symbol
|
||||
- `bgc`: Background color of the monitor. Only supports hex color codes. DO NOT include the `#` symbol. Example: `ff0000`
|
||||
- `locale`: The locale of the monitor. You can pass the code of the locale you have enabled in your kener settings. Example: `en`, `fr` etc
|
||||
|
||||
### Demo
|
||||
|
||||
|
||||
@@ -33,14 +33,6 @@ Defaults to 3000 if not specified
|
||||
export PORT=4242
|
||||
```
|
||||
|
||||
## GH_TOKEN
|
||||
|
||||
A github token to read issues and create labels. This is required for **incident management**
|
||||
|
||||
```bash
|
||||
export GH_TOKEN=your-github-token
|
||||
```
|
||||
|
||||
## KENER_BASE_PATH
|
||||
|
||||
By default kener runs on `/` but you can change it to `/status` or any other path.
|
||||
@@ -69,7 +61,7 @@ export RESEND_SENDER_EMAIL=Some Name <email@domain.com>
|
||||
```
|
||||
|
||||
<div class=" note danger ">
|
||||
Please note that the RESEND_API_KEY is required for sending emails. If you do not set this, Kener will not be able to send emails. RESEND_SENDER_EMAIL is a must if you forget your password.
|
||||
Please note that the RESEND_API_KEY is required for sending emails. If you do not set this, Kener will not be able to send emails. RESEND_SENDER_EMAIL is a must if you forget your password. If you do not want to use resend, you can set SMTP variables.
|
||||
</div>
|
||||
|
||||
## DATABASE_URL
|
||||
@@ -88,6 +80,23 @@ Set the timezone for the server. Set it to UTC.
|
||||
export TZ=UTC
|
||||
```
|
||||
|
||||
## SMTP
|
||||
|
||||
Kener can also use SMTP to send emails. You can set the SMTP server details in the environment variables.
|
||||
|
||||
```bash
|
||||
export SMTP_HOST=smtp.example.com
|
||||
export SMTP_PORT=587
|
||||
export SMTP_USER=username
|
||||
export SMTP_PASS=password
|
||||
export SMTP_SECURE=0
|
||||
export SMTP_FROM_EMAIL=Some Name <user@example.com>
|
||||
```
|
||||
|
||||
<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>
|
||||
|
||||
## 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
|
||||
|
||||
+11
-4
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Kener - A Sveltekit NodeJS Status Page System
|
||||
description: Kener is an 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.
|
||||
title: Kener Documentation
|
||||
description: Kener is a feature-rich and modern status page system built with SvelteKit and NodeJS. It is open-source and free to use.
|
||||
---
|
||||
|
||||
# Kener - A Feature Rich & Modern Status Page
|
||||
@@ -59,19 +59,24 @@ Kener is status page system built with Sveltekit and NodeJS. It does not try to
|
||||
|
||||
It comes with all the basic asks for a status page. It is open-source and free to use.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Quick Deployment
|
||||
|
||||
[](https://railway.com/template/spSvic?referralCode=1Pn7vs)
|
||||
|
||||
## Features
|
||||
|
||||
### Monitoring and Tracking
|
||||
|
||||
- Advanced application performance monitoring tools
|
||||
- Types of Monitors: HTTP, TCP, DNS, ICMP etc
|
||||
- Real-time network monitor software capabilities
|
||||
- Polls HTTP endpoint or Push data to monitor using Rest APIs
|
||||
- Handles Timezones for visitors
|
||||
- Categorize Monitors into different Sections
|
||||
- Cron-based scheduling for monitors. Minimum per minute
|
||||
- Flexible monitor configuration using YAML
|
||||
- Construct complex API Polls - Chain, Secrets etc
|
||||
- Supports a Default Status for Monitors
|
||||
- Supports base path for hosting in k8s
|
||||
@@ -91,6 +96,7 @@ Kener name is derived from the word "Kene" which means "how is it going" in Assa
|
||||
|
||||
- Incident Management
|
||||
- Incident Communication
|
||||
- Comprehensive APIs for Incident Management
|
||||
|
||||
### User Experience and Design
|
||||
|
||||
@@ -99,6 +105,7 @@ Kener name is derived from the word "Kene" which means "how is it going" in Assa
|
||||
- User-friendly interface
|
||||
- Responsive design for various devices
|
||||
- Auto SEO and Social Media ready
|
||||
- Server Side Rendering
|
||||
|
||||
## Technologies used
|
||||
|
||||
|
||||
+41
-19
@@ -5,7 +5,7 @@ description: Kener supports multiple languages. You can add translations to your
|
||||
|
||||
# i18n
|
||||
|
||||
You can add translations to your site. By default it is set to `en`. Available translations are present in `/src/lib/locales/` folders in the root directory. You can add more translations by adding a new file in the `/src/lib/locales` folder.
|
||||
You can add translations to your site. By default it is set to `en`. Available translations are present in `src/lib/locales/` folders in the root directory. You can add more translations by adding a new file in the `src/lib/locales` folder.
|
||||
|
||||
## How to enable a translation
|
||||
|
||||
@@ -28,6 +28,7 @@ Copy and update the translation file in the `locales` folder. The translation fi
|
||||
|
||||
```json
|
||||
{
|
||||
"%status for %duration": "%status for %duration",
|
||||
"14 Days": "14 Days",
|
||||
"30 Days": "30 Days",
|
||||
"60 Days": "60 Days",
|
||||
@@ -35,52 +36,57 @@ Copy and update the translation file in the `locales` folder. The translation fi
|
||||
"90 Days": "90 Days",
|
||||
"Availability per Component": "Availability per Component",
|
||||
"Back": "Back",
|
||||
"Badge": "Badge",
|
||||
"Badge Copied": "Badge Copied",
|
||||
"Badge": "Badge",
|
||||
"Browse Events": "Browse Events",
|
||||
"Code Copied": "Code Copied",
|
||||
"Copy Code": "Copy Code",
|
||||
"Copy Link": "Copy Link",
|
||||
"Dark": "Dark",
|
||||
"Days": "Days",
|
||||
"DEGRADED": "DEGRADED",
|
||||
"DOWN": "DOWN",
|
||||
"Embed": "Embed",
|
||||
"Dark": "Dark",
|
||||
"Days": "Days",
|
||||
"Embed this monitor using <script> or <iframe> in your app.": "Embed this monitor using <script> or <iframe> in your app.",
|
||||
"Embed": "Embed",
|
||||
"Get SVG badge for this monitor": "Get SVG badge for this monitor",
|
||||
"Get a LIVE Status for this monitor": "Get a LIVE Status for this monitor",
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"Incident Updates": "Incident Updates",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Incident Updates": "Incident Updates",
|
||||
"LIVE Status": "LIVE Status",
|
||||
"Lasted for about %lastedFor": "Lasted for about %lastedFor",
|
||||
"Light": "Light",
|
||||
"Link Copied": "Link Copied",
|
||||
"LIVE Status": "LIVE Status",
|
||||
"Mode": "Mode",
|
||||
"MAINTENANCE": "MAINTENANCE",
|
||||
"MONITORING": "MONITORING",
|
||||
"Maintenance Completed": "Maintenance Completed",
|
||||
"Maintenance in Progress": "Maintenance in Progress",
|
||||
"Mode": "Mode",
|
||||
"No Data": "No Data",
|
||||
"No Incident in %date": "No Incident in %date",
|
||||
"No Incidents": "No Incidents",
|
||||
"No Incident On %date": "No Incident On %date",
|
||||
"No Monitor Found": "No Monitor Found",
|
||||
"No Updates Yet": "No Updates Yet",
|
||||
"Ongoing Incidents": "Ongoing Incidents",
|
||||
"Pinging": "Pinging",
|
||||
"Recent Incidents": "Recent Incidents",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"Share": "Share",
|
||||
"Recent Incidents": "Recent Incidents",
|
||||
"Recent Maintenances": "Recent Maintenances",
|
||||
"Share this monitor using a link with others": "Share this monitor using a link with others",
|
||||
"Share": "Share",
|
||||
"Standard": "Standard",
|
||||
"Started about %startedAt ago, lasted for about %lastedFor": "Started about %startedAt ago, lasted for about %lastedFor",
|
||||
"Started about %startedAt ago, still ongoing": "Started about %startedAt ago, still ongoing",
|
||||
"Starts in %startedAt": "Starts in %startedAt",
|
||||
"Starts in %startedAt, will last for about %lastedFor": "Starts in %startedAt, will last for about %lastedFor",
|
||||
"Status": "Status",
|
||||
"Started %startedAt, lasted for %lastedFor": "Started %startedAt, lasted for %lastedFor",
|
||||
"Started %startedAt, still ongoing": "Started %startedAt, still ongoing",
|
||||
"Starts %startedAt, will last for %lastedFor": "Starts %startedAt, will last for %lastedFor",
|
||||
"Starts %startedAt": "Starts %startedAt",
|
||||
"Status OK": "Status OK",
|
||||
"Status": "Status",
|
||||
"Theme": "Theme",
|
||||
"Today": "Today",
|
||||
"UP": "UP",
|
||||
"Upcoming Maintenance": "Upcoming Maintenance",
|
||||
"Updates": "Updates",
|
||||
"Uptime": "Uptime",
|
||||
"%status for %duration": "%status for %duration"
|
||||
"Uptime": "Uptime"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -90,7 +96,7 @@ Copy and update the translation file in the `locales` folder. The translation fi
|
||||
|
||||
### Step 2
|
||||
|
||||
Open `/locales/locales.json` and add the locale code and the name of the language.
|
||||
Open `src/lib/locales/locales.json` and add the locale code and the name of the language.
|
||||
|
||||
```json
|
||||
[
|
||||
@@ -123,6 +129,22 @@ Open `/locales/locales.json` and add the locale code and the name of the languag
|
||||
|
||||
### Step 3
|
||||
|
||||
Open `src/lib/i18n/client.js` and add the locale code to the `locales` array.
|
||||
|
||||
```js
|
||||
import { ru, enUS, hi, de, zhCN, vi, ja, nl, da, fr, ko } from "date-fns/locale";
|
||||
```
|
||||
|
||||
This should be valid for the date-fns library. You can find the list of locales [here](https://github.com/date-fns/date-fns/blob/9bb51691f201c3ec05ab832acbc5d478f2e5c47a/docs/i18nLocales.md).
|
||||
|
||||
Next add a mapping in the `locales` object.
|
||||
|
||||
```js
|
||||
const locales = { ru, en: enUS, hi, dk: de, "zh-CN": zhCN, vi, ja, nl, dk: da, fr, ko };
|
||||
```
|
||||
|
||||
### Step 4
|
||||
|
||||
Restart the server and you will see a dropdown in the navbar to select the language.
|
||||
|
||||
<div class="note info">
|
||||
|
||||
@@ -74,3 +74,7 @@ To close an incident, you need to add a message and set the status to `RESOLVED`
|
||||
## Add Monitors
|
||||
|
||||
To add monitors to the incident, you need to add the monitor tag to the incident. This will automatically add the monitor to the incident. You will also get the status of the monitor in the incident. This can be `DEGRADED` or `DOWN`.
|
||||
|
||||
## Maintenance
|
||||
|
||||
You can also create a maintenance incident. This is similar to an incident but is used to notify users about maintenance activities. Both start and end date and time are required for maintenance incidents.
|
||||
|
||||
+34
-4
@@ -45,13 +45,13 @@ The headers are used to define the headers that should be sent with the request.
|
||||
|
||||
The eval is used to define the JavaScript code that should be used to evaluate the response. It is optional and has be a valid JavaScript code.
|
||||
|
||||
This is a anonymous JS function, by default it looks like this.
|
||||
This is an anonymous JS function, it should return a **Promise**, that resolves or rejects to `{status, latency}`, by default it looks like this.
|
||||
|
||||
> **_NOTE:_** The eval function should always return a json object. The json object can have only status(UP/DOWN/DEGRADED) and lantecy(number)
|
||||
> **_NOTE:_** The eval function should always return a json object. The json object can have only status(UP/DOWN/DEGRADED) and latency(number)
|
||||
> `{status:"DEGRADED", latency: 200}`.
|
||||
|
||||
```javascript
|
||||
(function (statusCode, responseTime, responseDataBase64) {
|
||||
(async function (statusCode, responseTime, responseDataBase64) {
|
||||
let statusCodeShort = Math.floor(statusCode/100);
|
||||
let status = 'DOWN'
|
||||
if(statusCodeShort >=2 && statusCodeShort <= 3) {
|
||||
@@ -79,7 +79,7 @@ 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
|
||||
(function (statusCode, responseTime, responseDataBase64) {
|
||||
(async function (statusCode, responseTime, responseDataBase64) {
|
||||
const resp = atob(responseDataBase64); //convert base64 to string
|
||||
|
||||
let status = "DOWN";
|
||||
@@ -106,6 +106,36 @@ The following example shows how to use the eval function to evaluate the respons
|
||||
});
|
||||
```
|
||||
|
||||
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;
|
||||
|
||||
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
|
||||
|
||||
### Website Monitor
|
||||
|
||||
+117
-7
@@ -13,14 +13,124 @@ Ping monitors are used to monitor livenees of your servers. You can use Ping mon
|
||||
|
||||
</div>
|
||||
|
||||
## Host V4
|
||||
## Hosts
|
||||
|
||||
You can add as many IP addresses as you want to monitor. The IP address should be a valid IPv4 address. Example of IP4 address is `106.12.43.232`.
|
||||
You can add as many hosts as you want to monitor. The host can be an IP(IP4 and IP6) address or a domain name.
|
||||
|
||||
## Host V6
|
||||
- Type: Choose the type of host you want to monitor. It can be either `IP4` or `IP6` or `DOMAIN`.
|
||||
- Host: Enter the IP address or domain name of the host you want to monitor.
|
||||
- Timeout: Enter the timeout in milliseconds for each ping request of each host. For IP6 timeout is not supported.
|
||||
- Count: The number of pings you want to do for each host. The average latency of all the pings will be used to evaluate the response.
|
||||
|
||||
You can add as many IP addresses as you want to monitor. The IP address should be a valid IPv6 address. Example of IP6 address is `2001:0db8:85a3:0000:0000:8a2e:0370:7334`.
|
||||
## Eval
|
||||
|
||||
<p class="note danger">
|
||||
Please note that atleast one of the Host V4 or Host V6 is required.
|
||||
<p>
|
||||
The eval is used to define the JavaScript code that should be used to evaluate the response. It is optional and has be a valid JavaScript code.
|
||||
|
||||
This is an anonymous JS function, it should return a **Promise**, that resolves or rejects to `{status, latency}`, by default it looks like this.
|
||||
|
||||
> **_NOTE:_** The eval function should always return a json object. The json object can have only status(UP/DOWN/DEGRADED) and latency(number)
|
||||
> `{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);
|
||||
|
||||
let alive = arrayOfPings.reduce((acc, ping) => {
|
||||
return acc && ping.alive;
|
||||
}, true);
|
||||
|
||||
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);
|
||||
/*
|
||||
[
|
||||
{
|
||||
alive: true,
|
||||
min: '145.121',
|
||||
max: '149.740',
|
||||
avg: '147.430',
|
||||
latencies: [ 145.121, 149.74 ],
|
||||
latency: 145.121,
|
||||
host: '45.91.169.49',
|
||||
type: 'IP4'
|
||||
},
|
||||
{
|
||||
alive: true,
|
||||
min: '145.348',
|
||||
max: '149.143',
|
||||
avg: '147.245',
|
||||
latencies: [ 145.348, 149.143 ],
|
||||
latency: 145.348,
|
||||
host: 'kener.ing',
|
||||
type: 'DOMAIN'
|
||||
},
|
||||
{
|
||||
alive: true,
|
||||
min: '57.696',
|
||||
max: '58.330',
|
||||
avg: '58.013',
|
||||
latencies: [ 57.696, 58.33 ],
|
||||
latency: 57.696,
|
||||
host: '2404:6800:4003:c1c::66',
|
||||
type: 'IP6'
|
||||
}
|
||||
]
|
||||
*/
|
||||
```
|
||||
|
||||
### Understanding the Input
|
||||
|
||||
The input to the eval function is a base64 encoded string. You will have to decode it and then parse it to get the array of objects. Each object in the array represents the ping response of a host.
|
||||
|
||||
- `alive` is a boolean. It is true if the host is alive and false if the host is down.
|
||||
- `min` is a string. It is the minimum latency of the pings.
|
||||
- `max` is a string. It is the maximum latency of the pings.
|
||||
- `avg` is a string. It is the average latency of the pings.
|
||||
- `latencies` is an array of numbers. It is the latency of each ping.
|
||||
- `latency` is a number. It is the average latency of the pings.
|
||||
- `host` is a string. It is the host that was pinged.
|
||||
- `type` is a string. It is the type of the host. It can be either `IP4` or `IP6` or `DOMAIN`.
|
||||
|
||||
### Example
|
||||
|
||||
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);
|
||||
|
||||
let areAllOpen = arrayOfPings.reduce((acc, ping) => {
|
||||
return acc && ping.alive;
|
||||
}, true);
|
||||
|
||||
let avgLatency = latencyTotal / arrayOfPings.length;
|
||||
|
||||
if (areAllOpen && avgLatency > 10) {
|
||||
return {
|
||||
status: "DEGRADED",
|
||||
latency: avgLatency
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: areAllOpen ? "UP" : "DOWN",
|
||||
latency: avgLatency
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: TCP Monitors | Kener
|
||||
description: Learn how to set up and work with TCP monitors in kener.
|
||||
---
|
||||
|
||||
# TCP Monitors
|
||||
|
||||
TCP monitors are used to monitor the livenees of your servers. You can use TCP monitors to monitor the uptime of your servers and get notified when they are down.
|
||||
|
||||
<div class="border rounded-md">
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
## Hosts
|
||||
|
||||
You can add as many hosts as you want to monitor. The host can be an IP(IP4 and IP6) address or a domain name.
|
||||
|
||||
- Type: Choose the type of host you want to monitor. It can be either `IP4` or `IP6` or `DOMAIN`.
|
||||
- Host: Enter the IP address or domain name of the host you want to monitor.
|
||||
- Port: Enter the port number of the host you want to monitor.
|
||||
- Timeout: Enter the timeout in milliseconds for each ping request of each host
|
||||
|
||||
## Eval
|
||||
|
||||
The eval is used to define the JavaScript code that should be used to evaluate the response. It is optional and has be a valid JavaScript code.
|
||||
|
||||
This is an anonymous JS function, it should return a **Promise**, that resolves or rejects to `{status, latency}`, by default it looks like this.
|
||||
|
||||
> **_NOTE:_** The eval function should always return a json object. The json object can have only status(UP/DOWN/DEGRADED) and latency(number)
|
||||
> `{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);
|
||||
|
||||
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
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
- `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);
|
||||
/*
|
||||
[
|
||||
{
|
||||
status: 'open',
|
||||
latency: 31.93,
|
||||
host: '66.51.120.219',
|
||||
port: 465,
|
||||
type: 'IP4'
|
||||
},
|
||||
{
|
||||
status: 'open',
|
||||
latency: 47.041417,
|
||||
host: '2404:6800:4003:c1c::66',
|
||||
port: 80,
|
||||
type: 'IP6'
|
||||
},
|
||||
{
|
||||
status: 'open',
|
||||
latency: 82.1865,
|
||||
host: 'rajnandan.com',
|
||||
port: 80,
|
||||
type: 'DOMAIN'
|
||||
}
|
||||
]
|
||||
*/
|
||||
```
|
||||
|
||||
### Understanding the Input
|
||||
|
||||
The input to the eval function is a base64 encoded string. You will have to decode it and then parse it to get the array of objects. Each object in the array represents the ping response of a host.
|
||||
|
||||
- `host`: The host that was pinged.
|
||||
- `port`: The port that was pinged. Defaults to 80 if not provided.
|
||||
- `type`: The type of IP address. Can be `IP4` or `IP6`.
|
||||
- `status`: The status of the ping. Can be `open` , `error` or `timeout`.
|
||||
- `open`: The host is reachable.
|
||||
- `error`: There was an error while pinging the host.
|
||||
- `timeout`: The host did not respond in time.
|
||||
- `latency`: The time taken to ping the host. This is in milliseconds.
|
||||
|
||||
### Example
|
||||
|
||||
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);
|
||||
|
||||
let areAllOpen = arrayOfPings.reduce((acc, ping) => {
|
||||
if (ping.status === "open") {
|
||||
return acc && true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}, true);
|
||||
|
||||
let avgLatency = latencyTotal / arrayOfPings.length;
|
||||
|
||||
if (areAllOpen && avgLatency > 10) {
|
||||
return {
|
||||
status: "DEGRADED",
|
||||
latency: avgLatency
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: areAllOpen ? "UP" : "DOWN",
|
||||
latency: avgLatency
|
||||
};
|
||||
});
|
||||
```
|
||||
+1
-1
@@ -47,7 +47,7 @@ The image is used to display a logo or an image for the monitor. It is displayed
|
||||
|
||||
## Description
|
||||
|
||||
The description is used to describe the monitor. It is displayed on the status page and in the admin panel. It is optional.
|
||||
The description is used to describe the monitor. It is displayed on the status page and in the admin panel. It is optional. You can also write html in the description and can use tailwind classes.
|
||||
|
||||
## Cron
|
||||
|
||||
|
||||
+10
-2
@@ -40,7 +40,15 @@ cp .env.example .env
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Kener Development Server would be running at PORT 3000. Go to [http://localhost:3000](http://localhost:3000)
|
||||
Kener Development Server would be running at PORT 3000. Go to [http://localhost:3000](http://localhost:3000) to see the Kener in action.
|
||||
|
||||
<div class="note info">
|
||||
|
||||
Status page will be at [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
Status Page Manage Portal will be at [http://localhost:3000/manage/app/site](http://localhost:3000/manage/app/site)
|
||||
|
||||
</div>
|
||||
|
||||
## Create a new User
|
||||
|
||||
@@ -63,6 +71,6 @@ Learn how to configure kener by going through one of the topics
|
||||
- [Monitors](/docs/monitors): Learn how to set up and work with monitors in kener.
|
||||
- [Triggers](/docs/triggers): Learn how to set up and work with triggers in kener.
|
||||
- [Environment Variables](/docs/environment-vars): Learn how to set up and work with environment variables in kener.
|
||||
- [API](/docs/api): Learn how to use the API in kener.
|
||||
- [API](/docs/kener-apis): Learn how to use the API in kener.
|
||||
- [Databases](/docs/database): Learn how to set up and work with databases in kener.
|
||||
- [Theme](/docs/theme): Learn how to set up and work with theme in kener.
|
||||
|
||||
+13
-7
@@ -20,18 +20,24 @@ description: Roadmap for Kener
|
||||
|
||||
☑ ~~Support for github enterprise~~
|
||||
|
||||
☐ Add support for fly.io deployment
|
||||
|
||||
☐ Add to digital ocean marketplace
|
||||
|
||||
☐ Add to aws marketplace
|
||||
|
||||
☐ Add Rate limiting to APIs
|
||||
|
||||
☐ Add support for custom themes
|
||||
|
||||
☑ ~~Handle uploaded images for docker containers~~
|
||||
|
||||
☑ ~~Introduce sitemap.xml~~
|
||||
|
||||
☑ ~~Migrate from moment to date-fns for status page~~ (moment remains in dashboard. Will remove later)
|
||||
|
||||
☑ ~~Create Admin UI~~ Released in (3.0.0)
|
||||
|
||||
☐ ~~Webhook customization~~
|
||||
|
||||
☐ Monitor Update Screen
|
||||
|
||||
☐ i18n for admin dashboard
|
||||
|
||||
---
|
||||
|
||||
Request a feature or start a discussion [here](https://github.com/rajnandan1/kener/discussions/119)
|
||||
Request a feature or start a discussion [here](https://github.com/rajnandan1/kener/discussions/new/choose)
|
||||
|
||||
@@ -59,6 +59,11 @@
|
||||
"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",
|
||||
@@ -140,6 +145,11 @@
|
||||
{
|
||||
"sectionTitle": "Help",
|
||||
"children": [
|
||||
{
|
||||
"title": "Fonts",
|
||||
"link": "/docs/custom-fonts",
|
||||
"file": "/custom-fonts.md"
|
||||
},
|
||||
{
|
||||
"title": "Changelogs",
|
||||
"link": "/docs/changelogs",
|
||||
|
||||
+15
-1
@@ -81,7 +81,7 @@ You can change the colors of UP/DEGRADED/DOWN states of the monitor. You show ha
|
||||
|
||||
## Font
|
||||
|
||||
You can change the font of the status page. You can choose from a list of google fonts.
|
||||
You can change the font of the status page. You can choose from a list of google, or [bunny fonts](https://fonts.bunny.net/) (which is a fully GDPR-compliant, zero-tracking/no-logging CDN font service).
|
||||
|
||||
### Font URL
|
||||
|
||||
@@ -90,3 +90,17 @@ Add font url
|
||||
### Font Family
|
||||
|
||||
Add font family name
|
||||
|
||||
### Custom CSS
|
||||
|
||||
You can add custom CSS to your status page. This will be added to the head of the page. You can add custom CSS to your status page. This will be added to the head of the page.
|
||||
|
||||
```css
|
||||
.my-class {
|
||||
color: red;
|
||||
}
|
||||
```
|
||||
|
||||
<div class="note danger">
|
||||
Do not include <style> tags.
|
||||
</div>
|
||||
|
||||
+55
-18
@@ -93,20 +93,24 @@ Body of the webhook will be sent as below:
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Description |
|
||||
| --------------------- | ----------------------------------------------------------- |
|
||||
| id | Unique ID of the alert |
|
||||
| alert_name | Name of the alert |
|
||||
| severity | Severity of the alert. Can be `critical`, `warn` |
|
||||
| status | Status of the alert. Can be `TRIGGERED`, `RESOLVED` |
|
||||
| source | Source of the alert. Can be `Kener` |
|
||||
| timestamp | Timestamp of the alert |
|
||||
| description | Description of the alert. This you can customize. See below |
|
||||
| details | Details of the alert. |
|
||||
| details.metric | Name of the monitor |
|
||||
| details.current_value | Current value of the monitor |
|
||||
| details.threshold | Alert trigger threshold of the monitor |
|
||||
| actions | Actions to be taken. Link to view the monitor. |
|
||||
| Key | Description | Variable |
|
||||
| --------------------- | ----------------------------------------------------------- | ----------------------------- |
|
||||
| id | Unique ID of the alert | ${id} |
|
||||
| alert_name | Name of the alert | ${alert_name} |
|
||||
| severity | Severity of the alert. Can be `critical`, `warn` | ${severity} |
|
||||
| status | Status of the alert. Can be `TRIGGERED`, `RESOLVED` | ${status} |
|
||||
| source | Source of the alert. Can be `Kener` | ${source} |
|
||||
| timestamp | Timestamp of the alert | ${timestamp} |
|
||||
| description | Description of the alert. This you can customize. See below | ${description} |
|
||||
| details | Details of the alert. | - |
|
||||
| details.metric | Name of the monitor | ${metric} |
|
||||
| details.current_value | Current value of the monitor | ${current_value} |
|
||||
| details.threshold | Alert trigger threshold of the monitor | ${threshold} |
|
||||
| actions | Actions to be taken. Link to view the monitor. | ${action_text}, ${action_url} |
|
||||
|
||||
### Custom Body
|
||||
|
||||
You can customize the body of the webhook. You can use the variables mentioned above. If you are not using a json body then please make sure you are using the right content-type by setting custom headers. See examples below.
|
||||
|
||||
## Discord
|
||||
|
||||
@@ -185,7 +189,7 @@ The slack message when alert is `RESOLVED` will look like this
|
||||
|
||||
## Email
|
||||
|
||||
Email triggers are used to send an email when a monitor goes down or up.
|
||||
Email triggers are used to send an email when a monitor goes down or up. Kener supports sending emails via [resend](https://resend.com) or over SMTP.
|
||||
|
||||
<div class="border rounded-md">
|
||||
|
||||
@@ -193,11 +197,22 @@ Email triggers are used to send an email when a monitor goes down or up.
|
||||
|
||||
</div>
|
||||
|
||||
<div class="border px-2 rounded-md mt-4">
|
||||
### Resend
|
||||
|
||||
#### Note
|
||||
To send emails using Resend you just need to set `RESEND_API_KEY` in the environment variables.
|
||||
|
||||
Please make sure you have set the `RESEND_API_KEY` in the environment variables.
|
||||
### SMTP
|
||||
|
||||
To send emails using SMTP, please enter
|
||||
|
||||
- Host: SMTP server host
|
||||
- Port: SMTP server port
|
||||
- User: SMTP server username
|
||||
- Password: SMTP server password
|
||||
|
||||
<div class="note danger">
|
||||
|
||||
Since the password will be stored as plain text we encourage to use environment variables for the password. Let us say if you have an environment variable `SMTP_PASSWORD` then you can use it as `$SMTP_PASSWORD`.
|
||||
|
||||
</div>
|
||||
|
||||
@@ -250,3 +265,25 @@ Click on the ⚙️ to edit the trigger.
|
||||
### Deactivate Trigger
|
||||
|
||||
You can deactivate the trigger by switching the toggle to off. You cannot send message to a deactivated trigger. Any monitor with this trigger will not send any notifications.
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### Telegram
|
||||
|
||||
You can use the webhook trigger to send a message to a telegram channel. Enable `Use a custom webhook body`.
|
||||
|
||||
Set the URL to `https://api.telegram.org/bot[BOT_TOKEN]/sendMessage`. Replace [BOT_TOKEN] with your bot token.
|
||||
|
||||
```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"
|
||||
}
|
||||
```
|
||||
|
||||
If you want to send a message to a group, then replace `[CHAT_ID]` with the group id.
|
||||
|
||||
You can also use environment variables to store the bot token and chat id. In that case the URL will be `https://api.telegram.org/bot$BOT_TOKEN/sendMessage`. In the body you can use `"chat_id": "$CHAT_ID"`. Make sure you have set the `BOT_TOKEN` and `CHAT_ID` in the <a href="/docs/environment-vars#secrets">environment variables</a>.
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
Hello world
|
||||
<script
|
||||
async
|
||||
src="http://localhost:3000/embed/monitor-earth/js?theme=light&monitor=http://localhost:3000/embed/monitor-earth"
|
||||
src="http://localhost:3000/embed/monitor-earth/js?theme=dark&bgc=111&locale=hi&monitor=http://localhost:3000/embed/monitor-earth"
|
||||
></script>
|
||||
</body>
|
||||
</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 "$@"
|
||||
@@ -4,6 +4,7 @@ import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import express from "express";
|
||||
import Startup from "./src/lib/server/startup.js";
|
||||
import { GetSiteMap } from "./src/lib/server/controllers/controller.js";
|
||||
import fs from "fs-extra";
|
||||
import knex from "knex";
|
||||
import knexOb from "./knexfile.js";
|
||||
@@ -24,8 +25,16 @@ app.use((req, res, next) => {
|
||||
app.get(base + "/healthcheck", (req, res) => {
|
||||
res.end("ok");
|
||||
});
|
||||
app.get(base + "/sitemap.xml", async (req, res) => {
|
||||
res.header("Content-Type", "application/xml");
|
||||
res.send(await GetSiteMap());
|
||||
});
|
||||
//part /uploads server static files from static/uploads
|
||||
app.use(base + "/uploads", express.static("static/uploads"));
|
||||
|
||||
//set env variable for upload path
|
||||
process.env.UPLOAD_PATH = "./uploads";
|
||||
|
||||
app.use(base + "/uploads", express.static("uploads"));
|
||||
|
||||
try {
|
||||
const openapiJSON = fs.readFileSync("./openapi.json", "utf-8");
|
||||
@@ -85,8 +94,6 @@ app.listen(PORT, async () => {
|
||||
await runMigrations();
|
||||
await runSeed();
|
||||
await db.destroy();
|
||||
|
||||
Startup();
|
||||
console.log("Kener is running on port " + PORT + "!");
|
||||
});
|
||||
|
||||
Startup();
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export function up(knex) {
|
||||
return knex.schema.alterTable("incidents", function (table) {
|
||||
table.text("incident_type").defaultTo("INCIDENT");
|
||||
});
|
||||
}
|
||||
|
||||
export function down(knex) {
|
||||
return knex.schema.alterTable("incidents", function (table) {
|
||||
table.dropColumn("incident_type");
|
||||
});
|
||||
}
|
||||
Generated
+8540
-7853
File diff suppressed because it is too large
Load Diff
+111
-106
@@ -1,108 +1,113 @@
|
||||
{
|
||||
"name": "kener",
|
||||
"version": "3.0.0",
|
||||
"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.",
|
||||
"author": "Raj Nandan Sharma <rajnandan1@gmail.com>",
|
||||
"keywords": [
|
||||
"Node.js application",
|
||||
"Open-source status page",
|
||||
"Service monitoring tool",
|
||||
"Real-time incident management",
|
||||
"Customizable reporting",
|
||||
"Service outage tracker",
|
||||
"User-friendly dashboard",
|
||||
"Incident communication platform",
|
||||
"Scalable monitoring solution",
|
||||
"Community-driven software",
|
||||
"Website status tracker",
|
||||
"Incident response tool",
|
||||
"System status monitoring",
|
||||
"Service reliability management",
|
||||
"Incident alert system"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/rajnandan1/kener.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"configure": "node build.js",
|
||||
"migrate": "npx knex migrate:latest",
|
||||
"preseed": "npx knex migrate:latest",
|
||||
"seed": "npx knex seed:run",
|
||||
"predev": "npm run seed",
|
||||
"devschedule": "node src/lib/server/startup.js",
|
||||
"schedule": "node src/lib/server/startup.js",
|
||||
"development": "vite dev",
|
||||
"dev": "npm-run-all --parallel devschedule development",
|
||||
"prettify": "prettier --write .",
|
||||
"start": "node main.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^2.0.0",
|
||||
"@sveltejs/adapter-node": "^1.3.1",
|
||||
"@sveltejs/kit": "^1.27.4",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@zerodevx/svelte-toast": "^0.9.6",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"date-picker-svelte": "^2.15.1",
|
||||
"postcss": "^8.4.24",
|
||||
"postcss-load-config": "^4.0.1",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-svelte": "^3.2.3",
|
||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||
"svelte": "^4.0.5",
|
||||
"svelte-awesome-color-picker": "^3.1.4",
|
||||
"svelte-check": "^3.6.0",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^4.4.2"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.8.2",
|
||||
"@number-flow/svelte": "^0.2.1",
|
||||
"@scalar/express-api-reference": "^0.4.167",
|
||||
"analytics": "^0.8.14",
|
||||
"axios": "^1.6.2",
|
||||
"badge-maker": "^3.3.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"better-sqlite3": "^11.5.0",
|
||||
"bits-ui": "^0.9.9",
|
||||
"clsx": "^2.0.0",
|
||||
"croner": "^7.0.5",
|
||||
"dns2": "^2.1.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.18.2",
|
||||
"figlet": "^1.8.0",
|
||||
"fs-extra": "^11.1.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"knex": "^3.1.0",
|
||||
"lucide-svelte": "^0.292.0",
|
||||
"marked": "^11.1.1",
|
||||
"mode-watcher": "^0.4.1",
|
||||
"moment": "^2.29.4",
|
||||
"moment-timezone": "^0.5.43",
|
||||
"mysql2": "^3.12.0",
|
||||
"node-cache": "^5.1.2",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"pg": "^8.13.1",
|
||||
"pg-pool": "^3.7.0",
|
||||
"ping": "^0.4.4",
|
||||
"queue": "^7.0.0",
|
||||
"randomstring": "^1.3.0",
|
||||
"resend": "^4.0.1",
|
||||
"svelte-legos": "^0.2.5",
|
||||
"tailwind-merge": "^2.0.0",
|
||||
"tailwind-variants": "^0.1.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
"name": "kener",
|
||||
"version": "3.1.0",
|
||||
"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.",
|
||||
"author": "Raj Nandan Sharma <rajnandan1@gmail.com>",
|
||||
"keywords": [
|
||||
"Node.js application",
|
||||
"Open-source status page",
|
||||
"Service monitoring tool",
|
||||
"Real-time incident management",
|
||||
"Customizable reporting",
|
||||
"Service outage tracker",
|
||||
"User-friendly dashboard",
|
||||
"Incident communication platform",
|
||||
"Scalable monitoring solution",
|
||||
"Community-driven software",
|
||||
"Website status tracker",
|
||||
"Incident response tool",
|
||||
"System status monitoring",
|
||||
"Service reliability management",
|
||||
"Incident alert system"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/rajnandan1/kener.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"configure": "node build.js",
|
||||
"migrate": "npx knex migrate:latest",
|
||||
"preseed": "npx knex migrate:latest",
|
||||
"seed": "npx knex seed:run",
|
||||
"predev": "npm run seed",
|
||||
"devschedule": "node src/lib/server/startup.js",
|
||||
"schedule": "node src/lib/server/startup.js",
|
||||
"development": "vite dev",
|
||||
"generate-readme": "node scripts/generate-readme.js",
|
||||
"dev": "npm-run-all --parallel devschedule development",
|
||||
"prettify": "prettier --write .",
|
||||
"start": "node main.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^2.0.0",
|
||||
"@sveltejs/adapter-node": "^1.3.1",
|
||||
"@sveltejs/kit": "^1.27.4",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@zerodevx/svelte-toast": "^0.9.6",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"date-picker-svelte": "^2.15.1",
|
||||
"mustache": "^4.2.0",
|
||||
"postcss": "^8.4.24",
|
||||
"postcss-load-config": "^4.0.1",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-svelte": "^3.2.3",
|
||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||
"svelte": "^4.0.5",
|
||||
"svelte-awesome-color-picker": "^3.1.4",
|
||||
"svelte-check": "^3.6.0",
|
||||
"svelte-dnd-action": "^0.9.55",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^4.4.2"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.8.2",
|
||||
"@number-flow/svelte": "^0.2.1",
|
||||
"@scalar/express-api-reference": "^0.4.167",
|
||||
"analytics": "^0.8.14",
|
||||
"axios": "^1.6.2",
|
||||
"badge-maker": "^3.3.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"better-sqlite3": "^11.5.0",
|
||||
"bits-ui": "^0.9.9",
|
||||
"clsx": "^2.0.0",
|
||||
"croner": "^7.0.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"dns2": "^2.1.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.18.2",
|
||||
"figlet": "^1.8.0",
|
||||
"fs-extra": "^11.1.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"knex": "^3.1.0",
|
||||
"lucide-svelte": "^0.292.0",
|
||||
"marked": "^11.1.1",
|
||||
"mode-watcher": "^0.4.1",
|
||||
"moment": "^2.29.4",
|
||||
"moment-timezone": "^0.5.43",
|
||||
"mysql2": "^3.12.0",
|
||||
"node-cache": "^5.1.2",
|
||||
"nodemailer": "^6.10.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"pg": "^8.13.1",
|
||||
"pg-pool": "^3.7.0",
|
||||
"ping": "^0.4.4",
|
||||
"queue": "^7.0.0",
|
||||
"randomstring": "^1.3.0",
|
||||
"resend": "^4.0.1",
|
||||
"svelte-legos": "^0.2.5",
|
||||
"tailwind-merge": "^2.0.0",
|
||||
"tailwind-variants": "^0.1.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -3,8 +3,6 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
+634
-603
File diff suppressed because it is too large
Load Diff
+137
-8
@@ -114,27 +114,29 @@ const allRecordTypes = {
|
||||
ANY: 255
|
||||
};
|
||||
const ValidateIpAddress = function (input) {
|
||||
// Check if input is a valid IPv4 address
|
||||
// Check if input is a valid IPv4 address with an optional port
|
||||
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
if (ipv4Regex.test(input)) {
|
||||
return "IPv4";
|
||||
return "IP4";
|
||||
}
|
||||
|
||||
// Check if input is a valid IPv6 address
|
||||
const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
|
||||
// Improved IPv6 regex that better handles compressed notation
|
||||
const ipv6Regex =
|
||||
/^(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){0,7}:|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){6}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:)|(?:[0-9a-fA-F]{1,4}:){1,7}(?::|:[0-9a-fA-F]{1,4})|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?:(?::[0-9a-fA-F]{1,4}){1,6})|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:))$/;
|
||||
if (ipv6Regex.test(input)) {
|
||||
return "IPv6";
|
||||
return "IP6";
|
||||
}
|
||||
|
||||
// Check if input is a valid domain name
|
||||
// Check if input is a valid domain name with an optional port
|
||||
const domainRegex = /^[a-zA-Z0-9]+([\-\.]{1}[a-zA-Z0-9]+)*\.[a-zA-Z]{2,}$/;
|
||||
if (domainRegex.test(input)) {
|
||||
return "Domain Name";
|
||||
return "DOMAIN";
|
||||
}
|
||||
|
||||
// If none of the above conditions match, the input is invalid
|
||||
return "Invalid";
|
||||
};
|
||||
|
||||
function IsValidHost(domain) {
|
||||
const regex = /^[a-zA-Z0-9]+([\-\.]{1}[a-zA-Z0-9]+)*\.[a-zA-Z]{2,}$/;
|
||||
return regex.test(domain);
|
||||
@@ -147,7 +149,132 @@ function IsValidNameServer(nameServer) {
|
||||
const IsValidURL = function (url) {
|
||||
return /^(http|https):\/\/[^ "]+$/.test(url);
|
||||
};
|
||||
function ValidateCronExpression(cronExp) {
|
||||
// Check if expression is provided and is a string
|
||||
if (!cronExp || typeof cronExp !== "string") {
|
||||
return { isValid: false, message: "Cron expression must be a non-empty string" };
|
||||
}
|
||||
|
||||
// Split the expression into its components
|
||||
const fields = cronExp.trim().split(/\s+/);
|
||||
|
||||
// Standard cron should have 5 or 6 fields
|
||||
// minute hour day-of-month month day-of-week [year]
|
||||
if (fields.length < 5 || fields.length > 6) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: "Cron expression must have 5 or 6 fields"
|
||||
};
|
||||
}
|
||||
|
||||
// Define field constraints
|
||||
const fieldConstraints = [
|
||||
{ name: "minute", min: 0, max: 59 },
|
||||
{ name: "hour", min: 0, max: 23 },
|
||||
{ name: "day", min: 1, max: 31 },
|
||||
{ name: "month", min: 1, max: 12 },
|
||||
{ name: "weekday", min: 0, max: 6 },
|
||||
{ name: "year", min: 1970, max: 2099 } // Optional field
|
||||
];
|
||||
|
||||
// Valid characters in cron expressions
|
||||
const validChars = /^[\d/*,\-]+$/;
|
||||
|
||||
// Validate each field
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const field = fields[i];
|
||||
const constraint = fieldConstraints[i];
|
||||
|
||||
// Check for valid characters
|
||||
if (!validChars.test(field)) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: `Invalid characters in ${constraint.name} field`
|
||||
};
|
||||
}
|
||||
|
||||
// Handle special characters
|
||||
if (field === "*") {
|
||||
continue; // Asterisk is valid for all fields
|
||||
}
|
||||
|
||||
// Handle lists (comma-separated values)
|
||||
if (field.includes(",")) {
|
||||
const values = field.split(",");
|
||||
for (const value of values) {
|
||||
if (!isValidRange(value, constraint.min, constraint.max)) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: `Invalid value in ${constraint.name} field: ${value}`
|
||||
};
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle ranges (with hyphens)
|
||||
if (field.includes("-")) {
|
||||
const [start, end] = field.split("-").map(Number);
|
||||
if (
|
||||
start == null ||
|
||||
end == null ||
|
||||
start < constraint.min ||
|
||||
end > constraint.max ||
|
||||
start > end
|
||||
) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: `Invalid range in ${constraint.name} field: ${field}`
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle steps (with forward slash)
|
||||
if (field.includes("/")) {
|
||||
const [range, step] = field.split("/");
|
||||
if (range !== "*" && !isValidRange(range, constraint.min, constraint.max)) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: `Invalid range in ${constraint.name} field: ${range}`
|
||||
};
|
||||
}
|
||||
if (!isValidRange(step, 1, constraint.max)) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: `Invalid step value in ${constraint.name} field: ${step}`
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle plain numbers
|
||||
if (!isValidRange(field, constraint.min, constraint.max)) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: `Invalid value in ${constraint.name} field: ${field}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true, message: "Valid cron expression" };
|
||||
}
|
||||
|
||||
function isValidRange(value, min, max) {
|
||||
const num = Number(value);
|
||||
return !isNaN(num) && num >= min && num <= max;
|
||||
}
|
||||
function SortMonitor(monitorSort, resp) {
|
||||
let monitors = [];
|
||||
if (!!monitorSort && monitorSort.length > 0) {
|
||||
monitors = monitorSort.map((id) => resp.find((m) => m.id == id)).filter((m) => !!m);
|
||||
//append any new monitors
|
||||
monitors = [...monitors, ...resp.filter((m) => !monitorSort.includes(m.id))];
|
||||
} else {
|
||||
monitors = resp;
|
||||
}
|
||||
return monitors;
|
||||
}
|
||||
export {
|
||||
siteDataExtractFromDb,
|
||||
storeSiteData,
|
||||
@@ -155,5 +282,7 @@ export {
|
||||
ValidateIpAddress,
|
||||
IsValidHost,
|
||||
IsValidNameServer,
|
||||
IsValidURL
|
||||
IsValidURL,
|
||||
ValidateCronExpression,
|
||||
SortMonitor
|
||||
};
|
||||
|
||||
@@ -1,132 +1,167 @@
|
||||
<script>
|
||||
import moment from "moment";
|
||||
import * as Accordion from "$lib/components/ui/accordion";
|
||||
import { l } from "$lib/i18n/client";
|
||||
import { base } from "$app/paths";
|
||||
export let incident;
|
||||
export let index;
|
||||
export let lang;
|
||||
let startTime = incident.start_date_time;
|
||||
// let startTime = moment(incident.incident_start_time * 1000).format("MMMM Do YYYY, h:mm:ss a");
|
||||
let endTime = parseInt(new Date() / 1000);
|
||||
let nowTime = parseInt(new Date() / 1000);
|
||||
if (incident.end_date_time) {
|
||||
endTime = incident.end_date_time;
|
||||
}
|
||||
let lastedFor = moment.duration(endTime - startTime, "seconds").humanize();
|
||||
let startedAt = moment.duration(nowTime - startTime, "seconds").humanize();
|
||||
let isFuture = false;
|
||||
//is future incident
|
||||
if (nowTime < startTime) {
|
||||
isFuture = true;
|
||||
}
|
||||
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);
|
||||
|
||||
let isFuture = false;
|
||||
//is future incident
|
||||
if (nowTime < startTime) {
|
||||
isFuture = true;
|
||||
}
|
||||
|
||||
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="justify-start text-left hover:no-underline">
|
||||
<p
|
||||
class="scroll-m-20 text-xs font-semibold leading-5 tracking-normal badge-{incident.state}"
|
||||
>
|
||||
{l(lang, incident.state)}
|
||||
</p>
|
||||
<p class="scroll-m-20 text-lg font-medium tracking-tight">
|
||||
{incident.title}
|
||||
</p>
|
||||
<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">
|
||||
<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-sm font-medium tracking-wide text-muted-foreground"
|
||||
>
|
||||
{#if !isFuture && incident.state != "RESOLVED"}
|
||||
<span>
|
||||
{l(lang, "Started about %startedAt ago, still ongoing", {
|
||||
startedAt
|
||||
})}
|
||||
</span>
|
||||
{:else if !isFuture && incident.state == "RESOLVED"}
|
||||
<span>
|
||||
{l(
|
||||
lang,
|
||||
"Started about %startedAt ago, lasted for about %lastedFor",
|
||||
{ startedAt, lastedFor }
|
||||
)}
|
||||
</span>
|
||||
{:else if isFuture && incident.state != "RESOLVED"}
|
||||
<span>
|
||||
{l(lang, "Starts in %startedAt", { startedAt })}
|
||||
</span>
|
||||
{:else if isFuture && incident.state == "RESOLVED"}
|
||||
<span>
|
||||
{l(
|
||||
lang,
|
||||
"Starts in %startedAt, will last for about %lastedFor",
|
||||
{ startedAt, lastedFor }
|
||||
)}
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
</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">
|
||||
Updates
|
||||
</p>
|
||||
{#if incident.comments.length > 0}
|
||||
<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"
|
||||
>
|
||||
{moment(comment.commented_at * 1000).format(
|
||||
"MMMM Do YYYY, h:mm:ss a"
|
||||
)}
|
||||
</time>
|
||||
<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>
|
||||
|
||||
<p class="mb-4 text-sm font-normal">
|
||||
{comment.comment}
|
||||
</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{:else}
|
||||
<p class="text-sm font-medium">
|
||||
{l(lang, "No Updates Yet")}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,391 +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 handleFileChangeLogo(event) {
|
||||
// const file = event.target.files[0];
|
||||
// if (file) {
|
||||
// const reader = new FileReader();
|
||||
// reader.onload = () => {
|
||||
// siteInformation.logo = reader.result;
|
||||
// };
|
||||
// reader.readAsDataURL(file);
|
||||
// }
|
||||
// }
|
||||
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 = base + "/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,364 +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.googleapis.com/css2?family=Albert+Sans:ital,wght@0,100..900;1,100..900&display=swap",
|
||||
family: "Albert Sans"
|
||||
},
|
||||
colors: "",
|
||||
font: ""
|
||||
};
|
||||
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.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&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>
|
||||
<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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+371
-389
@@ -1,408 +1,390 @@
|
||||
<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 {
|
||||
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, n, ampm } from "$lib/i18n/client";
|
||||
import { hoverAction, clickOutsideAction, slide } from "svelte-legos";
|
||||
import LoaderBoxes from "$lib/components/loaderbox.svelte";
|
||||
import moment from "moment";
|
||||
import NumberFlow from "@number-flow/svelte";
|
||||
import Incident from "$lib/components/IncidentNew.svelte";
|
||||
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";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
import {
|
||||
Share2,
|
||||
Link,
|
||||
CopyCheck,
|
||||
Code,
|
||||
Settings,
|
||||
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";
|
||||
|
||||
export let monitor;
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let localTz;
|
||||
export let lang;
|
||||
export let embed = false;
|
||||
let _0Day = {};
|
||||
let _90Day = monitor.pageData._90Day;
|
||||
let uptime90Day = monitor.pageData.uptime90Day;
|
||||
let incidents = {};
|
||||
let dayIncidentsFull = [];
|
||||
export let monitor;
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
export let localTz;
|
||||
export let lang;
|
||||
export let embed = false;
|
||||
export let selectedLang = "en";
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
let _0Day = {};
|
||||
let _90Day = monitor.pageData._90Day;
|
||||
let uptime90Day = monitor.pageData.uptime90Day;
|
||||
let incidents = {};
|
||||
let dayIncidentsFull = [];
|
||||
|
||||
function scrollToRight() {
|
||||
setTimeout(() => {
|
||||
let divs = document.querySelectorAll(".daygrid90");
|
||||
divs.forEach((div) => {
|
||||
div.scrollLeft = div.scrollWidth;
|
||||
});
|
||||
}, 1000 * 0.2);
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
let uptimesRollers = [
|
||||
{
|
||||
text: `${l(lang, "90 Days")}`,
|
||||
startTs: moment().subtract(90, "days").startOf("day").unix(),
|
||||
value: uptime90Day
|
||||
},
|
||||
{
|
||||
text: `${l(lang, "60 Days")}`,
|
||||
startTs: moment().subtract(59, "days").startOf("day").unix(),
|
||||
value: uptime90Day
|
||||
},
|
||||
{
|
||||
text: `${l(lang, "30 Days")}`,
|
||||
startTs: moment().subtract(29, "days").startOf("day").unix()
|
||||
},
|
||||
{
|
||||
text: `${l(lang, "14 Days")}`,
|
||||
startTs: moment().subtract(13, "days").startOf("day").unix()
|
||||
},
|
||||
{
|
||||
text: `${l(lang, "7 Days")}`,
|
||||
startTs: moment().subtract(6, "days").startOf("day").unix()
|
||||
},
|
||||
{
|
||||
text: l(lang, "Today"),
|
||||
startTs: moment().startOf("day").unix()
|
||||
}
|
||||
];
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
//start of the week moment
|
||||
let rolledAt = 0;
|
||||
let rollerLoading = false;
|
||||
async function rollSummary(r) {
|
||||
let newRolledAt = (rolledAt + r) % uptimesRollers.length;
|
||||
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()))
|
||||
}
|
||||
];
|
||||
|
||||
if (uptimesRollers[newRolledAt].value === undefined) {
|
||||
rollerLoading = true;
|
||||
uptimesRollers[newRolledAt].loading = true;
|
||||
//start of the week moment
|
||||
let rolledAt = 0;
|
||||
let rollerLoading = false;
|
||||
async function rollSummary(r) {
|
||||
let newRolledAt = (rolledAt + r) % uptimesRollers.length;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (uptimesRollers[newRolledAt].value === undefined) {
|
||||
rollerLoading = true;
|
||||
uptimesRollers[newRolledAt].loading = true;
|
||||
|
||||
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 = moment(new Date(bar.timestamp * 1000)).format("dddd, MMMM Do, YYYY");
|
||||
showDailyDataModal = true;
|
||||
loadingDayData = true;
|
||||
dayIncidentsFull = [];
|
||||
setTimeout(() => {
|
||||
getToday(bar.timestamp, incidentIDs);
|
||||
}, 750);
|
||||
}
|
||||
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);
|
||||
}
|
||||
</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>
|
||||
</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}"
|
||||
>
|
||||
{l(lang, summaryTime(monitor.pageData.summaryStatus), {
|
||||
status: monitor.pageData.summaryStatus,
|
||||
duration: monitor.pageData.summaryDuration
|
||||
})}
|
||||
</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]);
|
||||
}}
|
||||
style="transition: opacity {bar.ij * 2 + 100}ms ease-in;"
|
||||
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">
|
||||
{moment(new Date(bar.timestamp * 1000)).format(
|
||||
"dddd, MMMM Do, YYYY"
|
||||
)} -
|
||||
{l(lang, summaryTime(bar.summaryStatus), {
|
||||
status: bar.summaryStatus,
|
||||
duration: bar.summaryDuration
|
||||
})}
|
||||
</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>
|
||||
{new Date(
|
||||
bar.timestamp * 1000
|
||||
).toLocaleTimeString()}
|
||||
</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
|
||||
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>
|
||||
</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>
|
||||
|
||||
+261
-267
@@ -1,295 +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, n, ampm } 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;
|
||||
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">
|
||||
{@html l(
|
||||
lang,
|
||||
"Embed this moni2tor 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>
|
||||
|
||||
+54
-43
@@ -1,24 +1,61 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import { format, formatDistance, formatDistanceToNow, formatDuration } from "date-fns";
|
||||
import { ru, enUS, hi, de, zhCN, vi, ja, nl, da, fr, ko, ptBR, tr, nb } from "date-fns/locale";
|
||||
|
||||
const locales = {
|
||||
ru,
|
||||
en: enUS,
|
||||
hi,
|
||||
de,
|
||||
"zh-CN": zhCN,
|
||||
vi,
|
||||
ja,
|
||||
nl,
|
||||
dk: da,
|
||||
fr,
|
||||
ko,
|
||||
"pt-BR": ptBR,
|
||||
tr,
|
||||
"nb-NO": nb
|
||||
};
|
||||
|
||||
const f = function (date, formatStr, locale) {
|
||||
return format(date, formatStr, {
|
||||
locale: locales[locale] || enUS
|
||||
});
|
||||
};
|
||||
|
||||
const fd = function (start, end, locale) {
|
||||
return formatDistance(start, end, { addSuffix: false, locale: locales[locale] });
|
||||
};
|
||||
const fdn = function (start, locale) {
|
||||
return formatDistanceToNow(start, { addSuffix: true, locale: locales[locale] });
|
||||
};
|
||||
const fdm = function (duration, locale) {
|
||||
return formatDuration(duration, {
|
||||
format: ["days", "hours", "minutes"],
|
||||
zero: false,
|
||||
delimiter: " ",
|
||||
locale: locales[locale]
|
||||
});
|
||||
};
|
||||
|
||||
const l = function (sessionLangMap, key, args = {}) {
|
||||
const keys = key.split(".");
|
||||
let obj = sessionLangMap;
|
||||
try {
|
||||
let obj = sessionLangMap[key];
|
||||
|
||||
for (const keyPart of keys) {
|
||||
obj = obj?.[keyPart];
|
||||
if (!obj) {
|
||||
break;
|
||||
// Replace placeholders in the string using the args object
|
||||
if (obj && typeof obj === "string") {
|
||||
obj = obj.replace(/%\w+/g, (placeholder) => {
|
||||
const argKey = placeholder.slice(1); // Remove the `%` to get the key
|
||||
return args[argKey] !== undefined ? args[argKey] : placeholder;
|
||||
});
|
||||
}
|
||||
return obj || key;
|
||||
} catch (e) {
|
||||
return key;
|
||||
}
|
||||
|
||||
// Replace placeholders in the string using the args object
|
||||
if (obj && typeof obj === "string") {
|
||||
obj = obj.replace(/%\w+/g, (placeholder) => {
|
||||
const argKey = placeholder.slice(1); // Remove the `%` to get the key
|
||||
return args[argKey] !== undefined ? args[argKey] : placeholder;
|
||||
});
|
||||
}
|
||||
|
||||
return obj || key;
|
||||
};
|
||||
const summaryTime = function (summaryStatus) {
|
||||
if (summaryStatus == "No Data") {
|
||||
@@ -31,30 +68,4 @@ const summaryTime = function (summaryStatus) {
|
||||
return "%status for %duration";
|
||||
};
|
||||
|
||||
const n = function (
|
||||
/** @type {{ numbers: { [x: string]: any; }; }} */ sessionLangMap,
|
||||
/** @type {string} */ inputString
|
||||
) {
|
||||
const translations = sessionLangMap.numbers;
|
||||
console.trace("translations", translations);
|
||||
// @ts-ignore
|
||||
return inputString.replace(
|
||||
/\d/g,
|
||||
(/** @type {string | number} */ match) => translations[match] || match
|
||||
);
|
||||
};
|
||||
const ampm = function (
|
||||
/** @type {{ monitor: { [x: string]: any; }; }} */ sessionLangMap,
|
||||
/** @type {string} */ inputString
|
||||
) {
|
||||
const translations = sessionLangMap.monitor;
|
||||
|
||||
// @ts-ignore
|
||||
let resp = inputString.replace(/(am|pm)/g, function (/** @type {string | number} */ match) {
|
||||
return translations[match] || match;
|
||||
});
|
||||
|
||||
return resp;
|
||||
};
|
||||
|
||||
export { l, summaryTime, n, ampm };
|
||||
export { l, summaryTime, f, formatDistance, fd, fdn, fdm };
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"%status for %duration": "%status für %duration",
|
||||
"14 Days": "14 Tage",
|
||||
"30 Days": "30 Tage",
|
||||
"60 Days": "60 Tage",
|
||||
"7 Days": "7 Tage",
|
||||
"90 Days": "90 Tage",
|
||||
"Availability per Component": "Verfügbarkeit pro Komponente",
|
||||
"Back": "Zurück",
|
||||
"Badge Copied": "Abzeichen kopiert",
|
||||
"Badge": "Abzeichen",
|
||||
"Browse Events": "Ereignisse durchsuchen",
|
||||
"Code Copied": "Code kopiert",
|
||||
"Copy Code": "Code kopieren",
|
||||
"Copy Link": "Link kopieren",
|
||||
"DEGRADED": "EINGESCHRÄNKT",
|
||||
"DOWN": "AUSGEFALLEN",
|
||||
"Dark": "Dunkel",
|
||||
"Days": "Tage",
|
||||
"Embed this monitor using <script> or <iframe> in your app.": "Binden Sie diesen Monitor mit <script> oder <iframe> in Ihre App ein.",
|
||||
"Embed": "Einbetten",
|
||||
"Get SVG badge for this monitor": "SVG-Abzeichen für diesen Monitor erhalten",
|
||||
"Get a LIVE Status for this monitor": "LIVE-Status für diesen Monitor erhalten",
|
||||
"IDENTIFIED": "IDENTIFIZIERT",
|
||||
"INVESTIGATING": "UNTERSUCHUNG",
|
||||
"Incident Updates": "Vorfall-Updates",
|
||||
"LIVE Status": "LIVE-Status",
|
||||
"Lasted for about %lastedFor": "Dauerte etwa %lastedFor",
|
||||
"Light": "Hell",
|
||||
"Link Copied": "Link kopiert",
|
||||
"MAINTENANCE": "WARTUNG",
|
||||
"MONITORING": "ÜBERWACHUNG",
|
||||
"Maintenance Completed": "Wartung abgeschlossen",
|
||||
"Maintenance in Progress": "Wartung läuft",
|
||||
"Mode": "Modus",
|
||||
"No Data": "Keine Daten",
|
||||
"No Incident in %date": "Kein Vorfall am %date",
|
||||
"No Incidents": "Keine Vorfälle",
|
||||
"No Monitor Found": "Kein Monitor gefunden",
|
||||
"No Updates Yet": "Noch keine Updates",
|
||||
"Ongoing Incidents": "Laufende Vorfälle",
|
||||
"Pinging": "Pingen",
|
||||
"RESOLVED": "GELÖST",
|
||||
"Recent Incidents": "Kürzliche Vorfälle",
|
||||
"Recent Maintenances": "Kürzliche Wartungen",
|
||||
"Share this monitor using a link with others": "Teilen Sie diesen Monitor mit einem Link mit anderen",
|
||||
"Share": "Teilen",
|
||||
"Standard": "Standard",
|
||||
"Started %startedAt, lasted for %lastedFor": "Begann vor etwa %startedAt und dauerte etwa %lastedFor",
|
||||
"Started %startedAt, still ongoing": "Begann vor etwa %startedAt und dauert noch an",
|
||||
"Starts %startedAt, will last for %lastedFor": "Beginnt in %startedAt und wird etwa %lastedFor dauern",
|
||||
"Starts %startedAt": "Beginnt in %startedAt",
|
||||
"Status OK": "Status OK",
|
||||
"Status": "Status",
|
||||
"Theme": "Thema",
|
||||
"Today": "Heute",
|
||||
"UP": "VERFÜGBAR",
|
||||
"Upcoming Maintenance": "Bevorstehende Wartung",
|
||||
"Updates": "Updates",
|
||||
"Uptime": "Verfügbarkeit"
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"%status for %duration": "%status i %duration",
|
||||
"14 Days": "14 Dage",
|
||||
"30 Days": "30 Dage",
|
||||
"60 Days": "60 Dage",
|
||||
"7 Days": "7 Dage",
|
||||
"90 Days": "90 Dage",
|
||||
"Availability per Component": "Tilgængelighed pr. Komponent",
|
||||
"Back": "Tilbage",
|
||||
"Badge Copied": "Mærke Kopieret",
|
||||
"Badge": "Mærke",
|
||||
"Browse Events": "Gennemse begivenheder",
|
||||
"Code Copied": "Kode Kopieret",
|
||||
"Copy Code": "Kopier Kode",
|
||||
"Copy Link": "Kopier Link",
|
||||
"DEGRADED": "FORRINGET",
|
||||
"DOWN": "NEDE",
|
||||
"Dark": "Mørk",
|
||||
"Days": "Dage",
|
||||
"Embed this monitor using <script> or <iframe> in your app.": "Integrer denne monitor ved at bruge <script> eller <iframe> i din app.",
|
||||
"Embed": "Integrer",
|
||||
"Get SVG badge for this monitor": "Hent SVG-mærke til denne monitor",
|
||||
"Get a LIVE Status for this monitor": "Hent en LIVE-status for denne monitor",
|
||||
"IDENTIFIED": "IDENTIFICERET",
|
||||
"INVESTIGATING": "UNDERSØGER",
|
||||
"Incident Updates": "Hændelsesopdateringer",
|
||||
"LIVE Status": "LIVE-status",
|
||||
"Lasted for about %lastedFor": "Varede i cirka %lastedFor",
|
||||
"Light": "Lys",
|
||||
"Link Copied": "Link Kopieret",
|
||||
"MAINTENANCE": "VEDLIGEHOLDELSE",
|
||||
"MONITORING": "OVERVÅGNING",
|
||||
"Maintenance Completed": "Vedligeholdelse afsluttet",
|
||||
"Maintenance in Progress": "Vedligeholdelse i gang",
|
||||
"Mode": "Tilstand",
|
||||
"No Data": "Ingen Data",
|
||||
"No Incident in %date": "Ingen Hændelse Den %date",
|
||||
"No Incidents": "Ingen Hændelser",
|
||||
"No Monitor Found": "Ingen Monitor Fundet",
|
||||
"No Updates Yet": "Ingen Opdateringer Endnu",
|
||||
"Ongoing Incidents": "Igangværende Hændelser",
|
||||
"Pinging": "Pinger",
|
||||
"RESOLVED": "LØST",
|
||||
"Recent Incidents": "Seneste Hændelser",
|
||||
"Recent Maintenances": "Seneste Vedligeholdelser",
|
||||
"Share this monitor using a link with others": "Del denne monitor ved hjælp af et link med andre",
|
||||
"Share": "Del",
|
||||
"Standard": "Standard",
|
||||
"Started %startedAt, lasted for %lastedFor": "Startede for cirka %startedAt siden, varede i cirka %lastedFor",
|
||||
"Started %startedAt, still ongoing": "Startede for cirka %startedAt siden, stadig igangværende",
|
||||
"Starts %startedAt, will last for %lastedFor": "Starter om %startedAt, vil vare i cirka %lastedFor",
|
||||
"Starts %startedAt": "Starter om %startedAt",
|
||||
"Status OK": "Status OK",
|
||||
"Status": "Status",
|
||||
"Theme": "Tema",
|
||||
"Today": "I dag",
|
||||
"UP": "OPPE",
|
||||
"Upcoming Maintenance": "Kommende vedligeholdelse",
|
||||
"Updates": "Opdateringer",
|
||||
"Uptime": "Oppetid"
|
||||
}
|
||||
+23
-17
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"%status for %duration": "%status for %duration",
|
||||
"14 Days": "14 Days",
|
||||
"30 Days": "30 Days",
|
||||
"60 Days": "60 Days",
|
||||
@@ -6,50 +7,55 @@
|
||||
"90 Days": "90 Days",
|
||||
"Availability per Component": "Availability per Component",
|
||||
"Back": "Back",
|
||||
"Badge": "Badge",
|
||||
"Badge Copied": "Badge Copied",
|
||||
"Badge": "Badge",
|
||||
"Browse Events": "Browse Events",
|
||||
"Code Copied": "Code Copied",
|
||||
"Copy Code": "Copy Code",
|
||||
"Copy Link": "Copy Link",
|
||||
"Dark": "Dark",
|
||||
"Days": "Days",
|
||||
"DEGRADED": "DEGRADED",
|
||||
"DOWN": "DOWN",
|
||||
"Embed": "Embed",
|
||||
"Dark": "Dark",
|
||||
"Days": "Days",
|
||||
"Embed this monitor using <script> or <iframe> in your app.": "Embed this monitor using <script> or <iframe> in your app.",
|
||||
"Embed": "Embed",
|
||||
"Get SVG badge for this monitor": "Get SVG badge for this monitor",
|
||||
"Get a LIVE Status for this monitor": "Get a LIVE Status for this monitor",
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"Incident Updates": "Incident Updates",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Incident Updates": "Incident Updates",
|
||||
"LIVE Status": "LIVE Status",
|
||||
"Lasted for about %lastedFor": "Lasted for about %lastedFor",
|
||||
"Light": "Light",
|
||||
"Link Copied": "Link Copied",
|
||||
"LIVE Status": "LIVE Status",
|
||||
"Mode": "Mode",
|
||||
"MAINTENANCE": "MAINTENANCE",
|
||||
"MONITORING": "MONITORING",
|
||||
"Maintenance Completed": "Maintenance Completed",
|
||||
"Maintenance in Progress": "Maintenance in Progress",
|
||||
"Mode": "Mode",
|
||||
"No Data": "No Data",
|
||||
"No Incident in %date": "No Incident in %date",
|
||||
"No Incidents": "No Incidents",
|
||||
"No Incident On %date": "No Incident On %date",
|
||||
"No Monitor Found": "No Monitor Found",
|
||||
"No Updates Yet": "No Updates Yet",
|
||||
"Ongoing Incidents": "Ongoing Incidents",
|
||||
"Pinging": "Pinging",
|
||||
"Recent Incidents": "Recent Incidents",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"Share": "Share",
|
||||
"Recent Incidents": "Recent Incidents",
|
||||
"Recent Maintenances": "Recent Maintenances",
|
||||
"Share this monitor using a link with others": "Share this monitor using a link with others",
|
||||
"Share": "Share",
|
||||
"Standard": "Standard",
|
||||
"Started about %startedAt ago, lasted for about %lastedFor": "Started about %startedAt ago, lasted for about %lastedFor",
|
||||
"Started about %startedAt ago, still ongoing": "Started about %startedAt ago, still ongoing",
|
||||
"Starts in %startedAt": "Starts in %startedAt",
|
||||
"Starts in %startedAt, will last for about %lastedFor": "Starts in %startedAt, will last for about %lastedFor",
|
||||
"Status": "Status",
|
||||
"Started %startedAt, lasted for %lastedFor": "Started %startedAt, lasted for %lastedFor",
|
||||
"Started %startedAt, still ongoing": "Started %startedAt, still ongoing",
|
||||
"Starts %startedAt, will last for %lastedFor": "Starts %startedAt, will last for %lastedFor",
|
||||
"Starts %startedAt": "Starts %startedAt",
|
||||
"Status OK": "Status OK",
|
||||
"Status": "Status",
|
||||
"Theme": "Theme",
|
||||
"Today": "Today",
|
||||
"UP": "UP",
|
||||
"Upcoming Maintenance": "Upcoming Maintenance",
|
||||
"Updates": "Updates",
|
||||
"Uptime": "Uptime",
|
||||
"%status for %duration": "%status for %duration"
|
||||
"Uptime": "Uptime"
|
||||
}
|
||||
|
||||
+23
-17
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"%status for %duration": "%status pendant %duration",
|
||||
"14 Days": "14 jours",
|
||||
"30 Days": "30 jours",
|
||||
"60 Days": "60 jours",
|
||||
@@ -6,50 +7,55 @@
|
||||
"90 Days": "90 jours",
|
||||
"Availability per Component": "Disponibilité par composant",
|
||||
"Back": "Retour",
|
||||
"Badge": "Badge",
|
||||
"Badge Copied": "Badge copié",
|
||||
"Badge": "Badge",
|
||||
"Browse Events": "Parcourir les événements",
|
||||
"Code Copied": "Code copié",
|
||||
"Copy Code": "Copier le code",
|
||||
"Copy Link": "Copier le lien",
|
||||
"Dark": "Sombre",
|
||||
"Days": "Jours",
|
||||
"DEGRADED": "Dégradé",
|
||||
"DOWN": "Hors ligne",
|
||||
"Embed": "Intégrer",
|
||||
"Dark": "Sombre",
|
||||
"Days": "Jours",
|
||||
"Embed this monitor using <script> or <iframe> in your app.": "Intégrez ce moniteur dans votre application en utilisant <script> ou <iframe>.",
|
||||
"Embed": "Intégrer",
|
||||
"Get SVG badge for this monitor": "Obtenez un badge SVG pour ce moniteur",
|
||||
"Get a LIVE Status for this monitor": "Obtenez un statut LIVE pour ce moniteur",
|
||||
"IDENTIFIED": "Identifié",
|
||||
"Incident Updates": "Mises à jour des incidents",
|
||||
"INVESTIGATING": "En cours d'investigation",
|
||||
"Incident Updates": "Mises à jour des incidents",
|
||||
"LIVE Status": "Statut LIVE",
|
||||
"Lasted for about %lastedFor": "A duré environ %lastedFor",
|
||||
"Light": "Clair",
|
||||
"Link Copied": "Lien copié",
|
||||
"LIVE Status": "Statut LIVE",
|
||||
"Mode": "Mode",
|
||||
"MAINTENANCE": "Maintenance",
|
||||
"MONITORING": "En surveillance",
|
||||
"Maintenance Completed": "Maintenance terminée",
|
||||
"Maintenance in Progress": "Maintenance en cours",
|
||||
"Mode": "Mode",
|
||||
"No Data": "Pas de données",
|
||||
"No Incident in %date": "Pas d'incident le %date",
|
||||
"No Incidents": "Pas d'incidents",
|
||||
"No Incident On %date": "Pas d'incident le %date",
|
||||
"No Monitor Found": "Aucun moniteur trouvé",
|
||||
"No Updates Yet": "Pas encore de mises à jour",
|
||||
"Ongoing Incidents": "Incidents en cours",
|
||||
"Pinging": "Ping en cours",
|
||||
"Recent Incidents": "Incidents récents",
|
||||
"RESOLVED": "Résolu",
|
||||
"Share": "Partager",
|
||||
"Recent Incidents": "Incidents récents",
|
||||
"Recent Maintenances": "Maintenances récentes",
|
||||
"Share this monitor using a link with others": "Partagez ce moniteur avec un lien à d'autres personnes",
|
||||
"Share": "Partager",
|
||||
"Standard": "Standard",
|
||||
"Started about %startedAt ago, lasted for about %lastedFor": "Commencé il y a environ %startedAt, a duré environ %lastedFor",
|
||||
"Started about %startedAt ago, still ongoing": "Commencé il y a environ %startedAt, toujours en cours",
|
||||
"Starts in %startedAt": "Commence dans %startedAt",
|
||||
"Starts in %startedAt, will last for about %lastedFor": "Commence dans %startedAt, durera environ %lastedFor",
|
||||
"Status": "Statut",
|
||||
"Started %startedAt, lasted for %lastedFor": "Commencé il y a environ %startedAt, a duré environ %lastedFor",
|
||||
"Started %startedAt, still ongoing": "Commencé il y a environ %startedAt, toujours en cours",
|
||||
"Starts %startedAt, will last for %lastedFor": "Commence dans %startedAt, durera environ %lastedFor",
|
||||
"Starts %startedAt": "Commence dans %startedAt",
|
||||
"Status OK": "Statut OK",
|
||||
"Status": "Statut",
|
||||
"Theme": "Thème",
|
||||
"Today": "Aujourd'hui",
|
||||
"UP": "En ligne",
|
||||
"Upcoming Maintenance": "Maintenance à venir",
|
||||
"Updates": "Mises à jour",
|
||||
"Uptime": "Temps de fonctionnement",
|
||||
"%status for %duration": "%status pendant %duration"
|
||||
"Uptime": "Temps de fonctionnement"
|
||||
}
|
||||
|
||||
+24
-18
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"%status for %duration": "%duration के लिए %status",
|
||||
"14 Days": "14 दिन",
|
||||
"30 Days": "30 दिन",
|
||||
"60 Days": "60 दिन",
|
||||
@@ -6,50 +7,55 @@
|
||||
"90 Days": "90 दिन",
|
||||
"Availability per Component": "प्रत्येक घटक की उपलब्धता",
|
||||
"Back": "वापस",
|
||||
"Badge": "बैज",
|
||||
"Badge Copied": "बैज कॉपी किया गया",
|
||||
"Badge": "बैज",
|
||||
"Browse Events": "ईवेंट ब्राउज़ करें",
|
||||
"Code Copied": "कोड कॉपी किया गया",
|
||||
"Copy Code": "कोड कॉपी करें",
|
||||
"Copy Link": "लिंक कॉपी करें",
|
||||
"Dark": "डार्क",
|
||||
"Days": "दिन",
|
||||
"DEGRADED": "क्षीण",
|
||||
"DOWN": "डाउन",
|
||||
"Embed": "एम्बेड",
|
||||
"Dark": "डार्क",
|
||||
"Days": "दिन",
|
||||
"Embed this monitor using <script> or <iframe> in your app.": "इस मॉनिटर को अपनी ऐप में <script> या <iframe> का उपयोग करके एम्बेड करें।",
|
||||
"Embed": "एम्बेड",
|
||||
"Get SVG badge for this monitor": "इस मॉनिटर के लिए SVG बैज प्राप्त करें",
|
||||
"Get a LIVE Status for this monitor": "इस मॉनिटर के लिए लाइव स्टेटस प्राप्त करें",
|
||||
"IDENTIFIED": "पहचाना गया",
|
||||
"Incident Updates": "घटना अपडेट",
|
||||
"INVESTIGATING": "जांच चल रही है",
|
||||
"Lasted for about %lastedFor": "लगभग %lastedFor तक चला",
|
||||
"Incident Updates": "घटना अपडेट",
|
||||
"LIVE Status": "लाइव स्टेटस",
|
||||
"Lasted for about %lastedFor": "%lastedFor तक चला",
|
||||
"Light": "लाइट",
|
||||
"Link Copied": "लिंक कॉपी किया गया",
|
||||
"LIVE Status": "लाइव स्टेटस",
|
||||
"Mode": "मोड",
|
||||
"MAINTENANCE": "मेंटेनेंस",
|
||||
"MONITORING": "निगरानी",
|
||||
"Maintenance Completed": "मेंटेनेंस पूर्ण हुआ",
|
||||
"Maintenance in Progress": "मेंटेनेंस जारी है",
|
||||
"Mode": "मोड",
|
||||
"No Data": "कोई डेटा नहीं",
|
||||
"No Incident in %date": "%date को कोई घटना नहीं",
|
||||
"No Incidents": "कोई घटना नहीं",
|
||||
"No Incident On %date": "%date को कोई घटना नहीं",
|
||||
"No Monitor Found": "कोई मॉनिटर नहीं मिला",
|
||||
"No Updates Yet": "अभी तक कोई अपडेट नहीं",
|
||||
"Ongoing Incidents": "चल रही घटनाएँ",
|
||||
"Pinging": "पिंगिंग",
|
||||
"Recent Incidents": "हाल की घटनाएँ",
|
||||
"RESOLVED": "सुलझाया गया",
|
||||
"Share": "साझा करें",
|
||||
"Recent Incidents": "हाल की घटनाएँ",
|
||||
"Recent Maintenances": "हाल की मेंटेनेंस",
|
||||
"Share this monitor using a link with others": "इस मॉनिटर को दूसरों के साथ लिंक के माध्यम से साझा करें",
|
||||
"Share": "साझा करें",
|
||||
"Standard": "मानक",
|
||||
"Started about %startedAt ago, lasted for about %lastedFor": "लगभग %startedAt पहले शुरू हुआ, लगभग %lastedFor तक चला",
|
||||
"Started about %startedAt ago, still ongoing": "लगभग %startedAt पहले शुरू हुआ, अभी भी जारी है",
|
||||
"Starts in %startedAt": "%startedAt में शुरू होगा",
|
||||
"Starts in %startedAt, will last for about %lastedFor": "%startedAt में शुरू होगा, लगभग %lastedFor तक चलेगा",
|
||||
"Status": "स्थिति",
|
||||
"Started %startedAt, lasted for %lastedFor": "%startedAt शुरू हुआ, %lastedFor तक चला",
|
||||
"Started %startedAt, still ongoing": "%startedAt शुरू हुआ, अभी भी जारी है",
|
||||
"Starts %startedAt, will last for %lastedFor": "%startedAt में शुरू होगा, %lastedFor तक चलेगा",
|
||||
"Starts %startedAt": "%startedAt में शुरू होगा",
|
||||
"Status OK": "स्थिति ठीक है",
|
||||
"Status": "स्थिति",
|
||||
"Theme": "थीम",
|
||||
"Today": "आज",
|
||||
"UP": "अप",
|
||||
"Upcoming Maintenance": "आगामी मेंटेनेंस",
|
||||
"Updates": "अपडेट्स",
|
||||
"Uptime": "अपटाइम",
|
||||
"%status for %duration": "%duration के लिए %status"
|
||||
"Uptime": "अपटाइम"
|
||||
}
|
||||
|
||||
+23
-17
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"%status for %duration": "%duration の間 %status",
|
||||
"14 Days": "14日間",
|
||||
"30 Days": "30日間",
|
||||
"60 Days": "60日間",
|
||||
@@ -6,50 +7,55 @@
|
||||
"90 Days": "90日間",
|
||||
"Availability per Component": "コンポーネントごとの可用性",
|
||||
"Back": "戻る",
|
||||
"Badge": "バッジ",
|
||||
"Badge Copied": "バッジがコピーされました",
|
||||
"Badge": "バッジ",
|
||||
"Browse Events": "イベントを閲覧",
|
||||
"Code Copied": "コードがコピーされました",
|
||||
"Copy Code": "コードをコピー",
|
||||
"Copy Link": "リンクをコピー",
|
||||
"Dark": "ダーク",
|
||||
"Days": "日間",
|
||||
"DEGRADED": "劣化",
|
||||
"DOWN": "ダウン",
|
||||
"Embed": "埋め込み",
|
||||
"Dark": "ダーク",
|
||||
"Days": "日間",
|
||||
"Embed this monitor using <script> or <iframe> in your app.": "このモニターをアプリに <script> または <iframe> を使用して埋め込む。",
|
||||
"Embed": "埋め込み",
|
||||
"Get SVG badge for this monitor": "このモニターのSVGバッジを取得",
|
||||
"Get a LIVE Status for this monitor": "このモニターのライブステータスを取得",
|
||||
"IDENTIFIED": "識別済み",
|
||||
"Incident Updates": "インシデント更新",
|
||||
"INVESTIGATING": "調査中",
|
||||
"Incident Updates": "インシデント更新",
|
||||
"LIVE Status": "ライブステータス",
|
||||
"Lasted for about %lastedFor": "約 %lastedFor 続きました",
|
||||
"Light": "ライト",
|
||||
"Link Copied": "リンクがコピーされました",
|
||||
"LIVE Status": "ライブステータス",
|
||||
"Mode": "モード",
|
||||
"MAINTENANCE": "メンテナンス",
|
||||
"MONITORING": "モニタリング中",
|
||||
"Maintenance Completed": "メンテナンス完了",
|
||||
"Maintenance in Progress": "メンテナンス中",
|
||||
"Mode": "モード",
|
||||
"No Data": "データなし",
|
||||
"No Incident in %date": "%date にインシデントなし",
|
||||
"No Incidents": "インシデントなし",
|
||||
"No Incident On %date": "%date にインシデントなし",
|
||||
"No Monitor Found": "モニターが見つかりません",
|
||||
"No Updates Yet": "まだ更新はありません",
|
||||
"Ongoing Incidents": "進行中のインシデント",
|
||||
"Pinging": "ピング中",
|
||||
"Recent Incidents": "最近のインシデント",
|
||||
"RESOLVED": "解決済み",
|
||||
"Share": "共有",
|
||||
"Recent Incidents": "最近のインシデント",
|
||||
"Recent Maintenances": "最近のメンテナンス",
|
||||
"Share this monitor using a link with others": "このモニターをリンクで他の人と共有",
|
||||
"Share": "共有",
|
||||
"Standard": "標準",
|
||||
"Started about %startedAt ago, lasted for about %lastedFor": "%startedAt 前に開始し、約 %lastedFor 続きました",
|
||||
"Started about %startedAt ago, still ongoing": "%startedAt 前に開始し、まだ進行中です",
|
||||
"Starts in %startedAt": "%startedAt 後に開始",
|
||||
"Starts in %startedAt, will last for about %lastedFor": "%startedAt 後に開始し、約 %lastedFor 続きます",
|
||||
"Status": "ステータス",
|
||||
"Started %startedAt, lasted for %lastedFor": "%startedAt 前に開始し、約 %lastedFor 続きました",
|
||||
"Started %startedAt, still ongoing": "%startedAt 前に開始し、まだ進行中です",
|
||||
"Starts %startedAt, will last for %lastedFor": "%startedAt 後に開始し、約 %lastedFor 続きます",
|
||||
"Starts %startedAt": "%startedAt 後に開始",
|
||||
"Status OK": "ステータス正常",
|
||||
"Status": "ステータス",
|
||||
"Theme": "テーマ",
|
||||
"Today": "今日",
|
||||
"UP": "稼働中",
|
||||
"Upcoming Maintenance": "今後のメンテナンス",
|
||||
"Updates": "更新",
|
||||
"Uptime": "稼働時間",
|
||||
"%status for %duration": "%duration の間 %status"
|
||||
"Uptime": "稼働時間"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"%status for %duration": "%duration 동안 %status 상태였음",
|
||||
"14 Days": "14 일",
|
||||
"30 Days": "30 ",
|
||||
"60 Days": "60 일",
|
||||
"7 Days": "7 일",
|
||||
"90 Days": "90 일",
|
||||
"Availability per Component": "항목당 가용성",
|
||||
"Back": "뒤로",
|
||||
"Badge Copied": "배지 복사됨",
|
||||
"Badge": "배지",
|
||||
"Browse Events": "이벤트 찾아보기",
|
||||
"Code Copied": "소스코드 복사됨",
|
||||
"Copy Code": "소스코드 복사",
|
||||
"Copy Link": "링크 복사",
|
||||
"DEGRADED": "성능 저하됨",
|
||||
"DOWN": "중단",
|
||||
"Dark": "다크",
|
||||
"Days": "일",
|
||||
"Embed this monitor using <script> or <iframe> in your app.": "<script> 나 <iframe> 태그를 사용하여 내 앱에 이 모니터링을 임베딩 해보세요.",
|
||||
"Embed": "임베드",
|
||||
"Get SVG badge for this monitor": "이 모니터링 항목에 대한 SVG 배지를 구해보세요.",
|
||||
"Get a LIVE Status for this monitor": "이 모니터링 항목에 대한 실시간 상태를 구해보세요.",
|
||||
"IDENTIFIED": "확인됨",
|
||||
"INVESTIGATING": "조사 중",
|
||||
"Incident Updates": "사건 보고서",
|
||||
"LIVE Status": "실시간 상태",
|
||||
"Lasted for about %lastedFor": "%lastedFor 동안 지속",
|
||||
"Light": "라이트",
|
||||
"Link Copied": "링크 복사됨",
|
||||
"MAINTENANCE": "유지보수",
|
||||
"MONITORING": "감시 중",
|
||||
"Maintenance Completed": "유지보수 완료",
|
||||
"Maintenance in Progress": "유지보수 진행 중",
|
||||
"Mode": "모드",
|
||||
"No Data": "데이터 없음",
|
||||
"No Incident in %date": "%date 에는 아무 사건도 없었음",
|
||||
"No Incidents": "사건 없음",
|
||||
"No Monitor Found": "모니터링 항목을 찾을 수 없음",
|
||||
"No Updates Yet": "아직 아무 업데이트도 없음",
|
||||
"Ongoing Incidents": "진행중 사건",
|
||||
"Pinging": "핑 요청",
|
||||
"RESOLVED": "해결됨",
|
||||
"Recent Incidents": "최근 사건",
|
||||
"Recent Maintenances": "최근 유지보수",
|
||||
"Share this monitor using a link with others": "링크를 사용하여 다른 사람들과 이 모니터링 데이터를 공유해보세요",
|
||||
"Share": "공유",
|
||||
"Standard": "기본",
|
||||
"Started %startedAt, lasted for %lastedFor": "%startedAt 전에 시작되었으며, %lastedFor 동안 지속되었습니다",
|
||||
"Started %startedAt, still ongoing": "%startedAt 전에 시작되었으며, 아직 진행중입니다",
|
||||
"Starts %startedAt, will last for %lastedFor": "%startedAt 전에 시작되었으며, %lastedFor 까지 지속될 예정입니다",
|
||||
"Starts %startedAt": "%startedAt 에 시작되었습니다",
|
||||
"Status OK": "양호",
|
||||
"Status": "상태",
|
||||
"Theme": "테마",
|
||||
"Today": "오늘",
|
||||
"UP": "작동 중",
|
||||
"Upcoming Maintenance": "예정된 유지보수",
|
||||
"Updates": "업데이트",
|
||||
"Uptime": "업타임"
|
||||
}
|
||||
@@ -22,5 +22,37 @@
|
||||
{
|
||||
"code": "fr",
|
||||
"name": "Français"
|
||||
},
|
||||
{
|
||||
"code": "de",
|
||||
"name": "Deutsch"
|
||||
},
|
||||
{
|
||||
"code": "ru",
|
||||
"name": "Русский"
|
||||
},
|
||||
{
|
||||
"code": "dk",
|
||||
"name": "Dansk"
|
||||
},
|
||||
{
|
||||
"code": "nl",
|
||||
"name": "Nederlands"
|
||||
},
|
||||
{
|
||||
"code": "ko",
|
||||
"name": "한국어"
|
||||
},
|
||||
{
|
||||
"code": "pt-BR",
|
||||
"name": "Português Brasileiro"
|
||||
},
|
||||
{
|
||||
"code": "tr",
|
||||
"name": "Türkçe"
|
||||
},
|
||||
{
|
||||
"code": "nb-NO",
|
||||
"name": "Norsk Bokmål"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"%status for %duration": "%status i %duration",
|
||||
"14 Days": "14 dager",
|
||||
"30 Days": "30 dager",
|
||||
"60 Days": "60 dager",
|
||||
"7 Days": "7 dager",
|
||||
"90 Days": "90 dager",
|
||||
"Availability per Component": "Tilgjengelighet per komponent",
|
||||
"Back": "Tilbake",
|
||||
"Badge Copied": "Merke kopiert",
|
||||
"Badge": "Merke",
|
||||
"Browse Events": "Bla gjennom hendelser",
|
||||
"Code Copied": "Kode kopiert",
|
||||
"Copy Code": "Kopier kode",
|
||||
"Copy Link": "Kopier lenke",
|
||||
"DEGRADED": "NEDSATT",
|
||||
"DOWN": "NEDE",
|
||||
"Dark": "Mørk",
|
||||
"Days": "Dager",
|
||||
"Embed this monitor using <script> or <iframe> in your app.": "Bygg inn denne monitoren ved å bruke <script> eller <iframe> i appen din.",
|
||||
"Embed": "Bygg inn",
|
||||
"Get SVG badge for this monitor": "Hent SVG-merke for denne monitoren",
|
||||
"Get a LIVE Status for this monitor": "Få en LIVE-status for denne monitoren",
|
||||
"IDENTIFIED": "IDENTIFISERT",
|
||||
"INVESTIGATING": "UNDERSØKER",
|
||||
"Incident Updates": "Hendelsesoppdateringer",
|
||||
"LIVE Status": "LIVE-status",
|
||||
"Lasted for about %lastedFor": "Varte i omtrent %lastedFor",
|
||||
"Light": "Lys",
|
||||
"Link Copied": "Lenke kopiert",
|
||||
"MAINTENANCE": "VEDLIKEHOLD",
|
||||
"MONITORING": "OVERVÅKER",
|
||||
"Maintenance Completed": "Vedlikehold fullført",
|
||||
"Maintenance in Progress": "Vedlikehold pågår",
|
||||
"Mode": "Modus",
|
||||
"No Data": "Ingen data",
|
||||
"No Incident in %date": "Ingen hendelser på %date",
|
||||
"No Incidents": "Ingen hendelser",
|
||||
"No Monitor Found": "Ingen monitor funnet",
|
||||
"No Updates Yet": "Ingen oppdateringer ennå",
|
||||
"Ongoing Incidents": "Pågående hendelser",
|
||||
"Pinging": "Pinger",
|
||||
"RESOLVED": "LØST",
|
||||
"Recent Incidents": "Nylige hendelser",
|
||||
"Recent Maintenances": "Nylige vedlikehold",
|
||||
"Share this monitor using a link with others": "Del denne monitoren med andre via lenke",
|
||||
"Share": "Del",
|
||||
"Standard": "Standard",
|
||||
"Started %startedAt, lasted for %lastedFor": "Startet %startedAt, varte i %lastedFor",
|
||||
"Started %startedAt, still ongoing": "Startet %startedAt, fortsatt pågår",
|
||||
"Starts %startedAt, will last for %lastedFor": "Starter %startedAt, vil vare i %lastedFor",
|
||||
"Starts %startedAt": "Starter %startedAt",
|
||||
"Status OK": "Status OK",
|
||||
"Status": "Status",
|
||||
"Theme": "Tema",
|
||||
"Today": "I dag",
|
||||
"UP": "OPPE",
|
||||
"Upcoming Maintenance": "Kommende vedlikehold",
|
||||
"Updates": "Oppdateringer",
|
||||
"Uptime": "Oppetid"
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"%status for %duration": "%status voor %duration",
|
||||
"14 Days": "14 dagen",
|
||||
"30 Days": "30 dagen",
|
||||
"60 Days": "60 dagen",
|
||||
"7 Days": "7 dagen",
|
||||
"90 Days": "90 dagen",
|
||||
"Availability per Component": "Beschikbaarheid per component",
|
||||
"Back": "Terug",
|
||||
"Badge Copied": "Badge gekopieerd",
|
||||
"Badge": "Badge",
|
||||
"Browse Events": "Evenementen bekijken",
|
||||
"Code Copied": "Code gekopieerd",
|
||||
"Copy Code": "Kopieer code",
|
||||
"Copy Link": "Kopieer link",
|
||||
"DEGRADED": "BEPERKT",
|
||||
"DOWN": "NIET BEREIKBAAR",
|
||||
"Dark": "Donker",
|
||||
"Days": "Dagen",
|
||||
"Embed this monitor using <script> or <iframe> in your app.": "Sluit deze monitor in met behulp van <script> of <iframe> in je app.",
|
||||
"Embed": "Insluiten",
|
||||
"Get SVG badge for this monitor": "SVG-badge ophalen voor deze monitor",
|
||||
"Get a LIVE Status for this monitor": "LIVE-status ophalen voor deze monitor",
|
||||
"IDENTIFIED": "GEIDENTIFICEERD",
|
||||
"INVESTIGATING": "ONDERZOEK BEZIG",
|
||||
"Incident Updates": "Incident updates",
|
||||
"LIVE Status": "LIVE status",
|
||||
"Lasted for about %lastedFor": "Duurde ongeveer %lastedFor",
|
||||
"Light": "Licht",
|
||||
"Link Copied": "Link gekopieerd",
|
||||
"MAINTENANCE": "ONDERHOUD",
|
||||
"MONITORING": "MONITOREN",
|
||||
"Maintenance Completed": "Onderhoud voltooid",
|
||||
"Maintenance in Progress": "Onderhoud bezig",
|
||||
"Mode": "Modus",
|
||||
"No Data": "Geen gegevens",
|
||||
"No Incident in %date": "Geen incident op %date",
|
||||
"No Incidents": "Geen incidenten",
|
||||
"No Monitor Found": "Geen monitor gevonden",
|
||||
"No Updates Yet": "Nog geen updates",
|
||||
"Ongoing Incidents": "Lopende incidenten",
|
||||
"Pinging": "Pingen",
|
||||
"RESOLVED": "OPGELOST",
|
||||
"Recent Incidents": "Recente incidenten",
|
||||
"Recent Maintenances": "Recente onderhoudsbeurten",
|
||||
"Share this monitor using a link with others": "Deel deze monitor met anderen via een link",
|
||||
"Share": "Delen",
|
||||
"Standard": "Standaard",
|
||||
"Started %startedAt, lasted for %lastedFor": "Begonnen ongeveer %startedAt geleden, duurde ongeveer %lastedFor",
|
||||
"Started %startedAt, still ongoing": "Begonnen ongeveer %startedAt geleden, nog steeds bezig",
|
||||
"Starts %startedAt, will last for %lastedFor": "Begint over %startedAt, zal ongeveer %lastedFor duren",
|
||||
"Starts %startedAt": "Begint over %startedAt",
|
||||
"Status OK": "Status OK",
|
||||
"Status": "Status",
|
||||
"Theme": "Thema",
|
||||
"Today": "Vandaag",
|
||||
"UP": "BEREIKBAAR",
|
||||
"Upcoming Maintenance": "Aankomend onderhoud",
|
||||
"Updates": "Updates",
|
||||
"Uptime": "Uptime"
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"%status for %duration": "%status por %duration",
|
||||
"14 Days": "14 Dias",
|
||||
"30 Days": "30 Dias",
|
||||
"60 Days": "60 Dias",
|
||||
"7 Days": "7 Dias",
|
||||
"90 Days": "90 Dias",
|
||||
"Availability per Component": "Disponibilidade por Componente",
|
||||
"Back": "Voltar",
|
||||
"Badge Copied": "Emblema Copiado",
|
||||
"Badge": "Emblema",
|
||||
"Browse Events": "Navegar Eventos",
|
||||
"Code Copied": "Código Copiado",
|
||||
"Copy Code": "Copiar Código",
|
||||
"Copy Link": "Copiar Link",
|
||||
"DEGRADED": "FALHA",
|
||||
"DOWN": "INDISPONÍVEL",
|
||||
"Dark": "Escuro",
|
||||
"Days": "Dias",
|
||||
"Embed this monitor using <script> or <iframe> in your app.": "Incorporar este monitor ao seu aplicativo utilizando <script> ou <iframe>.",
|
||||
"Embed": "Incorporar",
|
||||
"Get SVG badge for this monitor": "Obter emblema SVG deste monitor",
|
||||
"Get a LIVE Status for this monitor": "Obter indicador de status AO VIVO",
|
||||
"IDENTIFIED": "IDENTIFICADO",
|
||||
"INVESTIGATING": "INVESTIGANDO",
|
||||
"Incident Updates": "Atualizações do Incidente",
|
||||
"LIVE Status": "Status AO VIVO",
|
||||
"Lasted for about %lastedFor": "Durou aproximadamente %lastedFor",
|
||||
"Light": "Claro",
|
||||
"Link Copied": "Link Copiado",
|
||||
"MAINTENANCE": "MANUTENÇÃO",
|
||||
"MONITORING": "MONITORANDO",
|
||||
"Maintenance Completed": "Manutenção Concluída",
|
||||
"Maintenance in Progress": "Manutenção em Progresso",
|
||||
"Mode": "Modo",
|
||||
"No Data": "Sem dados",
|
||||
"No Incident in %date": "Nenhum incidente em %date",
|
||||
"No Incidents": "Sem Incidentes",
|
||||
"No Monitor Found": "Nenhum monitor encontrado",
|
||||
"No Updates Yet": "Nenhuma atualização ainda",
|
||||
"Ongoing Incidents": "Incidentes em Andamento",
|
||||
"Pinging": "Animado",
|
||||
"RESOLVED": "RESOLVIDO",
|
||||
"Recent Incidents": "Incidentes Recentes",
|
||||
"Recent Maintenances": "Manutenções Recentes",
|
||||
"Share this monitor using a link with others": "Compartilhar este monitor através do link",
|
||||
"Share": "Compartilhar",
|
||||
"Standard": "Padrão",
|
||||
"Started %startedAt, lasted for %lastedFor": "Ocorreu faz %startedAt e durou %lastedFor",
|
||||
"Started %startedAt, still ongoing": "Iniciou faz %startedAt, ainda em andamento",
|
||||
"Starts %startedAt, will last for %lastedFor": "Terá início em %startedAt, com duração de %lastedFor",
|
||||
"Starts %startedAt": "Inicia em %startedAt",
|
||||
"Status OK": "Status OK",
|
||||
"Status": "Status",
|
||||
"Theme": "Tema",
|
||||
"Today": "Hoje",
|
||||
"UP": "OPERACIONAL",
|
||||
"Upcoming Maintenance": "Manutenção Agendada",
|
||||
"Updates": "Atualizações",
|
||||
"Uptime": "Tempo de Atividade"
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"%status for %duration": "%status в течение %duration",
|
||||
"14 Days": "14 Дней",
|
||||
"30 Days": "30 Дней",
|
||||
"60 Days": "60 Дней",
|
||||
"7 Days": "7 Дней",
|
||||
"90 Days": "90 Дней",
|
||||
"Availability per Component": "Доступность по компонентам",
|
||||
"Back": "Назад",
|
||||
"Badge Copied": "Значок скопирован",
|
||||
"Badge": "Значок",
|
||||
"Browse Events": "Просмотр событий",
|
||||
"Code Copied": "Код скопирован",
|
||||
"Copy Code": "Копировать код",
|
||||
"Copy Link": "Копировать ссылку",
|
||||
"DEGRADED": "УХУДШЕНО",
|
||||
"DOWN": "НЕДОСТУПНО",
|
||||
"Dark": "Темная",
|
||||
"Days": "Дней",
|
||||
"Embed this monitor using <script> or <iframe> in your app.": "Встройте этот монитор используя <script> или <iframe> в ваше приложение.",
|
||||
"Embed": "Встроить",
|
||||
"Get SVG badge for this monitor": "Получить SVG значок для этого монитора",
|
||||
"Get a LIVE Status for this monitor": "Получить ЖИВОЙ статус для этого монитора",
|
||||
"IDENTIFIED": "ВЫЯВЛЕНО",
|
||||
"INVESTIGATING": "ИССЛЕДУЕТСЯ",
|
||||
"Incident Updates": "Обновления инцидента",
|
||||
"LIVE Status": "ЖИВОЙ статус",
|
||||
"Lasted for about %lastedFor": "Длилось около %lastedFor",
|
||||
"Light": "Светлая",
|
||||
"Link Copied": "Ссылка скопирована",
|
||||
"MAINTENANCE": "ОБСЛУЖИВАНИЕ",
|
||||
"MONITORING": "МОНИТОРИНГ",
|
||||
"Maintenance Completed": "Техническое обслуживание завершено",
|
||||
"Maintenance in Progress": "Техническое обслуживание",
|
||||
"Mode": "Режим",
|
||||
"No Data": "Нет данных",
|
||||
"No Incident in %date": "Нет инцидентов на %date",
|
||||
"No Incidents": "Нет инцидентов",
|
||||
"No Monitor Found": "Монитор не найден",
|
||||
"No Updates Yet": "Пока нет обновлений",
|
||||
"Ongoing Incidents": "Текущие инциденты",
|
||||
"Pinging": "Пингуется",
|
||||
"RESOLVED": "РЕШЕНО",
|
||||
"Recent Incidents": "Недавние инциденты",
|
||||
"Recent Maintenances": "Последние обслуживания",
|
||||
"Share this monitor using a link with others": "Поделитесь этим монитором с другими по ссылке",
|
||||
"Share": "Поделиться",
|
||||
"Standard": "Стандартная",
|
||||
"Started %startedAt, lasted for %lastedFor": "Началось около %startedAt назад, длилось около %lastedFor",
|
||||
"Started %startedAt, still ongoing": "Началось около %startedAt назад, все еще продолжается",
|
||||
"Starts %startedAt, will last for %lastedFor": "Начнется через %startedAt, продлится около %lastedFor",
|
||||
"Starts %startedAt": "Начнется через %startedAt",
|
||||
"Status OK": "Статус ОК",
|
||||
"Status": "Статус",
|
||||
"Theme": "Тема",
|
||||
"Today": "Сегодня",
|
||||
"UP": "РАБОТАЕТ",
|
||||
"Upcoming Maintenance": "Предстоящее техническое обслуживание",
|
||||
"Updates": "Обновления",
|
||||
"Uptime": "Время работы"
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"%status for %duration": "%duration boyunca %status",
|
||||
"14 Days": "14 Gün",
|
||||
"30 Days": "30 Gün",
|
||||
"60 Days": "60 Gün",
|
||||
"7 Days": "7 Gün",
|
||||
"90 Days": "90 Gün",
|
||||
"Availability per Component": "Bileşen Kullanılabilirliği",
|
||||
"Back": "Geri",
|
||||
"Badge Copied": "Rozet Kopyalandı",
|
||||
"Badge": "Rozet",
|
||||
"Browse Events": "Etkinliklere Göz At",
|
||||
"Code Copied": "Kod Kopyalandı",
|
||||
"Copy Code": "Kodu Kopyala",
|
||||
"Copy Link": "Bağlantıyı Kopyala",
|
||||
"DEGRADED": "BOZUK",
|
||||
"DOWN": "ÇALIŞMIYOR",
|
||||
"Dark": "Koyu",
|
||||
"Days": "Günler",
|
||||
"Embed this monitor using <script> or <iframe> in your app.": "Uygulamanıza <script> veya <iframe> kullanarak bu hizmeti yerleştirin.",
|
||||
"Embed": "Yerleştir",
|
||||
"Get SVG badge for this monitor": "Bu servis için SVG rozet alın",
|
||||
"Get a LIVE Status for this monitor": "Bu servis için CANLI Durum alın",
|
||||
"IDENTIFIED": "TANIMLANDI",
|
||||
"INVESTIGATING": "İNCELENİYOR",
|
||||
"Incident Updates": "Olay Güncellemeleri",
|
||||
"LIVE Status": "CANLI Durum",
|
||||
"Lasted for about %lastedFor": "%lastedFor sürdü",
|
||||
"Light": "Açık",
|
||||
"Link Copied": "Bağlantı Kopyalandı",
|
||||
"MAINTENANCE": "BAKIM",
|
||||
"MONITORING": "İZLEME",
|
||||
"Maintenance Completed": "Bakım Tamamlandı",
|
||||
"Maintenance in Progress": "Bakım Devam Ediyor",
|
||||
"Mode": "Mod",
|
||||
"No Data": "Veri Yok",
|
||||
"No Incident in %date": "%date Tarihinde Olay Yok",
|
||||
"No Incidents": "Olay Yok",
|
||||
"No Monitor Found": "Servis Bulunamadı",
|
||||
"No Updates Yet": "Henüz Güncelleme Yok",
|
||||
"Ongoing Incidents": "Devam Eden Olaylar",
|
||||
"Pinging": "Pinging",
|
||||
"RESOLVED": "ÇÖZÜLDÜ",
|
||||
"Recent Incidents": "Son Olaylar",
|
||||
"Recent Maintenances": "Son Bakımlar",
|
||||
"Share this monitor using a link with others": "Bu servisi bir bağlantı kullanarak başkalarıyla paylaşın",
|
||||
"Share": "Paylaş",
|
||||
"Standard": "Standart",
|
||||
"Started %startedAt, lasted for %lastedFor": "%startedAt başladı, %lastedFor sürdü",
|
||||
"Started %startedAt, still ongoing": "%startedAt başladı, devam ediyor",
|
||||
"Starts %startedAt, will last for %lastedFor": "%startedAt başlıyor, %lastedFor sürecek",
|
||||
"Starts %startedAt": "%startedAt başlıyor",
|
||||
"Status OK": "Durum UYGUN",
|
||||
"Status": "Durum",
|
||||
"Theme": "Tema",
|
||||
"Today": "Bugün",
|
||||
"UP": "ÇALIŞIYOR",
|
||||
"Upcoming Maintenance": "Yaklaşan Bakım",
|
||||
"Updates": "Güncellemeler",
|
||||
"Uptime": "Çalışma Süresi"
|
||||
}
|
||||
+23
-17
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"%status for %duration": "%status trong %duration",
|
||||
"14 Days": "14 ngày",
|
||||
"30 Days": "30 ngày",
|
||||
"60 Days": "60 ngày",
|
||||
@@ -6,50 +7,55 @@
|
||||
"90 Days": "90 ngày",
|
||||
"Availability per Component": "Tính khả dụng theo thành phần",
|
||||
"Back": "Quay lại",
|
||||
"Badge": "Huy hiệu",
|
||||
"Badge Copied": "Huy hiệu đã được sao chép",
|
||||
"Badge": "Huy hiệu",
|
||||
"Browse Events": "Duyệt sự kiện",
|
||||
"Code Copied": "Mã đã được sao chép",
|
||||
"Copy Code": "Sao chép mã",
|
||||
"Copy Link": "Sao chép liên kết",
|
||||
"Dark": "Tối",
|
||||
"Days": "Ngày",
|
||||
"DEGRADED": "Suy giảm",
|
||||
"DOWN": "Ngừng hoạt động",
|
||||
"Embed": "Nhúng",
|
||||
"Dark": "Tối",
|
||||
"Days": "Ngày",
|
||||
"Embed this monitor using <script> or <iframe> in your app.": "Nhúng trình theo dõi này vào ứng dụng của bạn bằng <script> hoặc <iframe>.",
|
||||
"Embed": "Nhúng",
|
||||
"Get SVG badge for this monitor": "Lấy huy hiệu SVG cho trình theo dõi này",
|
||||
"Get a LIVE Status for this monitor": "Lấy trạng thái TRỰC TIẾP cho trình theo dõi này",
|
||||
"IDENTIFIED": "Đã xác định",
|
||||
"Incident Updates": "Cập nhật sự cố",
|
||||
"INVESTIGATING": "Đang điều tra",
|
||||
"Incident Updates": "Cập nhật sự cố",
|
||||
"LIVE Status": "Trạng thái TRỰC TIẾP",
|
||||
"Lasted for about %lastedFor": "Kéo dài khoảng %lastedFor",
|
||||
"Light": "Sáng",
|
||||
"Link Copied": "Liên kết đã được sao chép",
|
||||
"LIVE Status": "Trạng thái TRỰC TIẾP",
|
||||
"Mode": "Chế độ",
|
||||
"MAINTENANCE": "Bảo trì",
|
||||
"MONITORING": "Đang giám sát",
|
||||
"Maintenance Completed": "Bảo trì đã hoàn thành",
|
||||
"Maintenance in Progress": "Bảo trì đang diễn ra",
|
||||
"Mode": "Chế độ",
|
||||
"No Data": "Không có dữ liệu",
|
||||
"No Incident in %date": "Không có sự cố vào %date",
|
||||
"No Incidents": "Không có sự cố",
|
||||
"No Incident On %date": "Không có sự cố vào %date",
|
||||
"No Monitor Found": "Không tìm thấy trình theo dõi",
|
||||
"No Updates Yet": "Chưa có cập nhật",
|
||||
"Ongoing Incidents": "Sự cố đang diễn ra",
|
||||
"Pinging": "Đang kiểm tra",
|
||||
"Recent Incidents": "Sự cố gần đây",
|
||||
"RESOLVED": "Đã giải quyết",
|
||||
"Share": "Chia sẻ",
|
||||
"Recent Incidents": "Sự cố gần đây",
|
||||
"Recent Maintenances": "Bảo trì gần đây",
|
||||
"Share this monitor using a link with others": "Chia sẻ trình theo dõi này với người khác bằng liên kết",
|
||||
"Share": "Chia sẻ",
|
||||
"Standard": "Tiêu chuẩn",
|
||||
"Started about %startedAt ago, lasted for about %lastedFor": "Bắt đầu khoảng %startedAt trước, kéo dài khoảng %lastedFor",
|
||||
"Started about %startedAt ago, still ongoing": "Bắt đầu khoảng %startedAt trước, vẫn đang diễn ra",
|
||||
"Starts in %startedAt": "Bắt đầu trong %startedAt",
|
||||
"Starts in %startedAt, will last for about %lastedFor": "Bắt đầu trong %startedAt, sẽ kéo dài khoảng %lastedFor",
|
||||
"Status": "Trạng thái",
|
||||
"Started %startedAt, lasted for %lastedFor": "Bắt đầu khoảng %startedAt trước, kéo dài khoảng %lastedFor",
|
||||
"Started %startedAt, still ongoing": "Bắt đầu khoảng %startedAt trước, vẫn đang diễn ra",
|
||||
"Starts %startedAt, will last for %lastedFor": "Bắt đầu trong %startedAt, sẽ kéo dài khoảng %lastedFor",
|
||||
"Starts %startedAt": "Bắt đầu trong %startedAt",
|
||||
"Status OK": "Trạng thái OK",
|
||||
"Status": "Trạng thái",
|
||||
"Theme": "Giao diện",
|
||||
"Today": "Hôm nay",
|
||||
"UP": "Hoạt động",
|
||||
"Upcoming Maintenance": "Bảo trì sắp tới",
|
||||
"Updates": "Cập nhật",
|
||||
"Uptime": "Thời gian hoạt động",
|
||||
"%status for %duration": "%status trong %duration"
|
||||
"Uptime": "Thời gian hoạt động"
|
||||
}
|
||||
|
||||
+23
-17
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"%status for %duration": "%duration 内的 %status",
|
||||
"14 Days": "14天",
|
||||
"30 Days": "30天",
|
||||
"60 Days": "60天",
|
||||
@@ -6,50 +7,55 @@
|
||||
"90 Days": "90天",
|
||||
"Availability per Component": "每个组件的可用性",
|
||||
"Back": "返回",
|
||||
"Badge": "徽章",
|
||||
"Badge Copied": "徽章已复制",
|
||||
"Badge": "徽章",
|
||||
"Browse Events": "浏览事件",
|
||||
"Code Copied": "代码已复制",
|
||||
"Copy Code": "复制代码",
|
||||
"Copy Link": "复制链接",
|
||||
"Dark": "深色",
|
||||
"Days": "天",
|
||||
"DEGRADED": "性能下降",
|
||||
"DOWN": "不可用",
|
||||
"Embed": "嵌入",
|
||||
"Dark": "深色",
|
||||
"Days": "天",
|
||||
"Embed this monitor using <script> or <iframe> in your app.": "使用 <script> 或 <iframe> 将此监控嵌入您的应用。",
|
||||
"Embed": "嵌入",
|
||||
"Get SVG badge for this monitor": "获取此监控的SVG徽章",
|
||||
"Get a LIVE Status for this monitor": "获取此监控的实时状态",
|
||||
"IDENTIFIED": "已识别",
|
||||
"Incident Updates": "事件更新",
|
||||
"INVESTIGATING": "正在调查",
|
||||
"Incident Updates": "事件更新",
|
||||
"LIVE Status": "实时状态",
|
||||
"Lasted for about %lastedFor": "持续了约 %lastedFor",
|
||||
"Light": "浅色",
|
||||
"Link Copied": "链接已复制",
|
||||
"LIVE Status": "实时状态",
|
||||
"Mode": "模式",
|
||||
"MAINTENANCE": "维护",
|
||||
"MONITORING": "正在监控",
|
||||
"Maintenance Completed": "维护已完成",
|
||||
"Maintenance in Progress": "维护进行中",
|
||||
"Mode": "模式",
|
||||
"No Data": "无数据",
|
||||
"No Incident in %date": "%date 无事件",
|
||||
"No Incidents": "无事件",
|
||||
"No Incident On %date": "%date 无事件",
|
||||
"No Monitor Found": "未找到监控",
|
||||
"No Updates Yet": "尚无更新",
|
||||
"Ongoing Incidents": "正在处理的事件",
|
||||
"Pinging": "正在测试",
|
||||
"Recent Incidents": "最近的事件",
|
||||
"RESOLVED": "已解决",
|
||||
"Share": "分享",
|
||||
"Recent Incidents": "最近的事件",
|
||||
"Recent Maintenances": "最近的维护",
|
||||
"Share this monitor using a link with others": "通过链接与他人分享此监控",
|
||||
"Share": "分享",
|
||||
"Standard": "标准",
|
||||
"Started about %startedAt ago, lasted for about %lastedFor": "大约 %startedAt 前开始,持续了约 %lastedFor",
|
||||
"Started about %startedAt ago, still ongoing": "大约 %startedAt 前开始,目前仍在进行中",
|
||||
"Starts in %startedAt": "%startedAt 后开始",
|
||||
"Starts in %startedAt, will last for about %lastedFor": "%startedAt 后开始,将持续约 %lastedFor",
|
||||
"Status": "状态",
|
||||
"Started %startedAt, lasted for %lastedFor": "大约 %startedAt 前开始,持续了约 %lastedFor",
|
||||
"Started %startedAt, still ongoing": "大约 %startedAt 前开始,目前仍在进行中",
|
||||
"Starts %startedAt, will last for %lastedFor": "%startedAt 后开始,将持续约 %lastedFor",
|
||||
"Starts %startedAt": "%startedAt 后开始",
|
||||
"Status OK": "状态正常",
|
||||
"Status": "状态",
|
||||
"Theme": "主题",
|
||||
"Today": "今天",
|
||||
"UP": "可用",
|
||||
"Upcoming Maintenance": "即将进行的维护",
|
||||
"Updates": "更新",
|
||||
"Uptime": "正常运行时间",
|
||||
"%status for %duration": "%duration 内的 %status"
|
||||
"Uptime": "正常运行时间"
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ async function createJSONCommonAlert(monitor, config, alert, severity) {
|
||||
let actions = [
|
||||
{
|
||||
text: "View Monitor",
|
||||
url: siteURL + "/monitor-" + monitor.tag
|
||||
url: siteURL + "?monitor=" + monitor.tag
|
||||
}
|
||||
];
|
||||
return {
|
||||
|
||||
+104
-83
@@ -10,90 +10,111 @@ const DOWN = "DOWN";
|
||||
const DEGRADED = "DEGRADED";
|
||||
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";
|
||||
|
||||
export {
|
||||
MONITOR,
|
||||
UP,
|
||||
DOWN,
|
||||
SITE,
|
||||
DEGRADED,
|
||||
API_TIMEOUT,
|
||||
ENV,
|
||||
AnalyticsProviders,
|
||||
AllRecordTypes,
|
||||
REALTIME,
|
||||
TIMEOUT,
|
||||
ERROR,
|
||||
MANUAL,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
|
||||
export function IsValidURL(url) {
|
||||
const regex =
|
||||
/^(https?:\/\/)?((localhost|[\da-z.-]+\.[a-z]{2,6})(:[0-9]{1,5})?)?(\/[\w .-]*)*\/?$/i;
|
||||
/^(https?:\/\/)?((localhost|[\da-z.-]+\.[a-z]{2,10})(:[0-9]{1,5})?)?(\/[\w .-]*)*\/?$/i;
|
||||
return regex.test(url);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,16 @@ export function IsValidJSONString(data) {
|
||||
return true;
|
||||
}
|
||||
|
||||
//IsValidJSONArray
|
||||
export function IsValidJSONArray(data) {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
return Array.isArray(data);
|
||||
}
|
||||
|
||||
export function IsValidNav(nav) {
|
||||
try {
|
||||
nav = JSON.parse(nav);
|
||||
|
||||
+139
-339
@@ -1,12 +1,9 @@
|
||||
// @ts-nocheck
|
||||
import axios from "axios";
|
||||
import ping from "ping";
|
||||
import { Ping, ExtractIPv6HostAndPort, TCP } from "./ping.js";
|
||||
import { UP, DOWN, DEGRADED } from "./constants.js";
|
||||
import {
|
||||
GetMinuteStartNowTimestampUTC,
|
||||
ReplaceAllOccurrences,
|
||||
GetRequiredSecrets
|
||||
} from "./tool.js";
|
||||
import Service from "./services/service.js";
|
||||
import { GetMinuteStartNowTimestampUTC, ReplaceAllOccurrences, GetRequiredSecrets, Wait } from "./tool.js";
|
||||
|
||||
import alerting from "./alerting.js";
|
||||
import Queue from "queue";
|
||||
@@ -24,366 +21,169 @@ 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)
|
||||
autostart: true // Automatically start the queue (optional)
|
||||
concurrency: 10, // Number of tasks that can run concurrently
|
||||
timeout: 10000, // Timeout in ms after which a task will be considered as failed (optional)
|
||||
autostart: true, // Automatically start the queue (optional)
|
||||
});
|
||||
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)
|
||||
autostart: true, // Automatically start the queue (optional)
|
||||
});
|
||||
|
||||
const defaultEval = `(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,
|
||||
}
|
||||
})`;
|
||||
|
||||
async function manualIncident(monitor) {
|
||||
let startTs = GetMinuteStartNowTimestampUTC();
|
||||
let impactArr = await db.getIncidentsByMonitorTagRealtime(monitor.tag, startTs);
|
||||
let startTs = GetMinuteStartNowTimestampUTC();
|
||||
let incidentArr = await db.getIncidentsByMonitorTagRealtime(monitor.tag, startTs);
|
||||
let maintenanceArr = await db.getMaintenanceByMonitorTagRealtime(monitor.tag, startTs);
|
||||
|
||||
let impact = "";
|
||||
if (impactArr.length == 0) {
|
||||
return {};
|
||||
}
|
||||
let impactArr = incidentArr.concat(maintenanceArr);
|
||||
|
||||
for (let i = 0; i < impactArr.length; i++) {
|
||||
const element = impactArr[i];
|
||||
let impact = "";
|
||||
if (impactArr.length == 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let autoIncidents = await db.getActiveAlertIncident(
|
||||
monitor.tag,
|
||||
element.monitor_impact,
|
||||
element.id
|
||||
);
|
||||
for (let i = 0; i < impactArr.length; i++) {
|
||||
const element = impactArr[i];
|
||||
|
||||
if (!!autoIncidents) {
|
||||
continue;
|
||||
}
|
||||
let autoIncidents = await db.getActiveAlertIncident(monitor.tag, element.monitor_impact, element.id);
|
||||
|
||||
if (element.monitor_impact === "DOWN") {
|
||||
impact = "DOWN";
|
||||
break;
|
||||
}
|
||||
if (element.monitor_impact === "DEGRADED") {
|
||||
impact = "DEGRADED";
|
||||
}
|
||||
}
|
||||
if (!!autoIncidents) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (impact === "") {
|
||||
return {};
|
||||
}
|
||||
if (element.monitor_impact === "DOWN") {
|
||||
impact = "DOWN";
|
||||
break;
|
||||
}
|
||||
if (element.monitor_impact === "DEGRADED") {
|
||||
impact = "DEGRADED";
|
||||
}
|
||||
}
|
||||
|
||||
let manualData = {
|
||||
[startTs]: {
|
||||
status: impact,
|
||||
latency: 0,
|
||||
type: MANUAL
|
||||
}
|
||||
};
|
||||
return manualData;
|
||||
if (impact === "") {
|
||||
return {};
|
||||
}
|
||||
|
||||
let manualData = {
|
||||
[startTs]: {
|
||||
status: impact,
|
||||
latency: 0,
|
||||
type: MANUAL,
|
||||
},
|
||||
};
|
||||
return manualData;
|
||||
}
|
||||
|
||||
const pingCall = async (hostsV4, hostsV6) => {
|
||||
if (hostsV4 === undefined) hostsV4 = [];
|
||||
if (hostsV6 === undefined) hostsV6 = [];
|
||||
let alive = true;
|
||||
let latencyTotal = 0;
|
||||
let countHosts = hostsV4.length + hostsV6.length;
|
||||
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");
|
||||
|
||||
for (let i = 0; i < hostsV4.length; i++) {
|
||||
const host = hostsV4[i].trim();
|
||||
try {
|
||||
let res = await ping.promise.probe(host);
|
||||
alive = alive && res.alive;
|
||||
latencyTotal += res.time;
|
||||
} catch (error) {
|
||||
alive = alive && false;
|
||||
latencyTotal += 30;
|
||||
}
|
||||
}
|
||||
let evalResp = undefined;
|
||||
|
||||
for (let i = 0; i < hostsV6.length; i++) {
|
||||
const host = hostsV6[i].trim();
|
||||
try {
|
||||
let res = await ping.promise.probe(host, {
|
||||
v6: true,
|
||||
timeout: false
|
||||
});
|
||||
alive = alive && res.alive;
|
||||
latencyTotal += res.time;
|
||||
} catch (error) {
|
||||
alive = alive && false;
|
||||
latencyTotal += 30;
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: alive ? UP : DOWN,
|
||||
latency: parseInt(latencyTotal / countHosts),
|
||||
type: REALTIME
|
||||
};
|
||||
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 apiCall = async (envSecrets, url, method, headers, body, timeout, monitorEval) => {
|
||||
let axiosHeaders = {};
|
||||
axiosHeaders["User-Agent"] = "Kener/2.0.0";
|
||||
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) {
|
||||
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 = eval(monitorEval + `(${statusCode}, ${latency}, "${resp}")`);
|
||||
} catch (error) {
|
||||
console.log("Error in monitorEval", 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 = {};
|
||||
let realTimeData = {};
|
||||
let manualData = {};
|
||||
|
||||
const startOfMinute = GetMinuteStartNowTimestampUTC();
|
||||
if (monitor.monitor_type === "API") {
|
||||
let envSecrets = GetRequiredSecrets(
|
||||
`${monitor.type_data.url} ${monitor.type_data.body} ${JSON.stringify(monitor.type_data.headers)}`
|
||||
);
|
||||
const startOfMinute = GetMinuteStartNowTimestampUTC();
|
||||
const serviceClient = new Service(monitor);
|
||||
if (monitor.monitor_type === "API") {
|
||||
let apiResponse = await serviceClient.execute();
|
||||
|
||||
if (monitor.type_data.eval === "") {
|
||||
monitor.type_data.eval = defaultEval;
|
||||
}
|
||||
realTimeData[startOfMinute] = apiResponse;
|
||||
|
||||
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
|
||||
);
|
||||
//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");
|
||||
serviceClient.execute().then(async (data) => {
|
||||
await db.insertMonitoringData({
|
||||
monitor_tag: monitor.tag,
|
||||
timestamp: startOfMinute,
|
||||
status: data.status,
|
||||
latency: data.latency,
|
||||
type: data.type,
|
||||
});
|
||||
cb();
|
||||
});
|
||||
});
|
||||
}
|
||||
} else if (monitor.monitor_type === "PING") {
|
||||
realTimeData[startOfMinute] = await serviceClient.execute();
|
||||
} else if (monitor.monitor_type === "TCP") {
|
||||
realTimeData[startOfMinute] = await serviceClient.execute();
|
||||
} else if (monitor.monitor_type === "DNS") {
|
||||
realTimeData[startOfMinute] = await serviceClient.execute();
|
||||
}
|
||||
|
||||
realTimeData[startOfMinute] = apiResponse;
|
||||
} else if (monitor.monitor_type === "PING") {
|
||||
let pingResponse = await pingCall(monitor.type_data.hostsV4, monitor.type_data.hostsV6);
|
||||
realTimeData[startOfMinute] = pingResponse;
|
||||
} 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;
|
||||
}
|
||||
manualData = await manualIncident(monitor);
|
||||
//merge noData, apiData, webhookData, dayData
|
||||
let mergedData = {};
|
||||
|
||||
manualData = await manualIncident(monitor);
|
||||
//merge noData, apiData, webhookData, dayData
|
||||
let mergedData = {};
|
||||
if (monitor.default_status !== undefined && monitor.default_status !== null) {
|
||||
if ([UP, DOWN, DEGRADED].indexOf(monitor.default_status) !== -1) {
|
||||
mergedData[startOfMinute] = {
|
||||
status: monitor.default_status,
|
||||
latency: 0,
|
||||
type: "default_status",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (monitor.default_status !== undefined && monitor.default_status !== null) {
|
||||
if ([UP, DOWN, DEGRADED].indexOf(monitor.default_status) !== -1) {
|
||||
mergedData[startOfMinute] = {
|
||||
status: monitor.default_status,
|
||||
latency: 0,
|
||||
type: "default_status"
|
||||
};
|
||||
}
|
||||
}
|
||||
for (const timestamp in realTimeData) {
|
||||
mergedData[timestamp] = realTimeData[timestamp];
|
||||
}
|
||||
|
||||
for (const timestamp in realTimeData) {
|
||||
mergedData[timestamp] = realTimeData[timestamp];
|
||||
}
|
||||
for (const timestamp in manualData) {
|
||||
mergedData[timestamp] = manualData[timestamp];
|
||||
}
|
||||
|
||||
for (const timestamp in manualData) {
|
||||
mergedData[timestamp] = manualData[timestamp];
|
||||
}
|
||||
|
||||
for (const timestamp in mergedData) {
|
||||
const element = mergedData[timestamp];
|
||||
db.insertMonitoringData({
|
||||
monitor_tag: monitor.tag,
|
||||
timestamp: parseInt(timestamp),
|
||||
status: element.status,
|
||||
latency: element.latency,
|
||||
type: element.type
|
||||
});
|
||||
}
|
||||
alertingQueue.push(async (cb) => {
|
||||
setTimeout(async () => {
|
||||
await alerting(monitor);
|
||||
cb();
|
||||
}, 1042);
|
||||
});
|
||||
for (const timestamp in mergedData) {
|
||||
const element = mergedData[timestamp];
|
||||
db.insertMonitoringData({
|
||||
monitor_tag: monitor.tag,
|
||||
timestamp: parseInt(timestamp),
|
||||
status: element.status,
|
||||
latency: element.latency,
|
||||
type: element.type,
|
||||
});
|
||||
}
|
||||
alertingQueue.push(async (cb) => {
|
||||
setTimeout(async () => {
|
||||
await alerting(monitor);
|
||||
cb();
|
||||
}, 1042);
|
||||
});
|
||||
};
|
||||
|
||||
alertingQueue.start((err) => {
|
||||
if (err) {
|
||||
console.error("Error occurred:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
if (err) {
|
||||
console.error("Error occurred:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
apiQueue.start((err) => {
|
||||
if (err) {
|
||||
console.error("Error occurred:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
export { Minuter };
|
||||
|
||||
+642
-629
File diff suppressed because it is too large
Load Diff
@@ -1,102 +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: "keywords",
|
||||
value: "Node.js status page, Incident management tool, Service monitoring, Service outage tracking, Real-time status updates, GitHub integration for incidents, Open-source status page, Node.js monitoring application, Service reliability, User-friendly incident management, Collaborative incident resolution, Seamless outage communication, Service disruption tracker, Real-time incident alerts, Node.js status reporting"
|
||||
},
|
||||
{
|
||||
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: "/docs/home", iconURL: "" },
|
||||
{ name: "Github", iconURL: "", url: "https://github.com/rajnandan1/kener" },
|
||||
{ name: "Buy me a coffee", iconURL: "", url: "https://buymeacoffee.com/rajnandan1" }
|
||||
],
|
||||
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 },
|
||||
{ code: "hi", name: "हिन्दी", selected: true, disabled: false },
|
||||
{ code: "zh_CN", name: "中文", selected: true, disabled: false },
|
||||
{ code: "ja", name: "日本語", selected: true, disabled: false },
|
||||
{ code: "vi", name: "Tiếng Việt", 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.googleapis.com/css2?family=Albert+Sans:ital,wght@0,100..900;1,100..900&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;
|
||||
|
||||
@@ -78,34 +78,3 @@ class DNSResolver {
|
||||
}
|
||||
|
||||
export default DNSResolver;
|
||||
// const resolver = new DNSResolver();
|
||||
// const domain = process.argv[2] || "google.com";
|
||||
// const recordType = process.argv[3] || "A";
|
||||
// resolver.getRecord(domain, recordType).then(
|
||||
// function (records) {
|
||||
// Object.entries(records).forEach(([type, records]) => {
|
||||
// if (records.length === 0) return []; // Skip empty records
|
||||
// return records;
|
||||
// console.log(">>>>>>---- dns:167 ", records);
|
||||
// console.log(`\n${type} Records:`);
|
||||
// records.forEach((record) => {
|
||||
// console.log("----------------------------------------");
|
||||
// console.log(`Type: ${type}`);
|
||||
// console.log(`Name: ${record.name}`);
|
||||
// console.log(`TTL: ${record.ttl}`);
|
||||
|
||||
// // Format the output based on record type
|
||||
// if (type === "MX") {
|
||||
// console.log(`Priority: ${record.data.priority}`);
|
||||
// console.log(`Exchange: ${record.data.exchange}`);
|
||||
// } else {
|
||||
// console.log(`Data: ${record.data}`);
|
||||
// // console.log(">>>>>>---- dns-resolver:120 ", record);
|
||||
// }
|
||||
// });
|
||||
// });
|
||||
// },
|
||||
// function (err) {
|
||||
// console.error(err);
|
||||
// }
|
||||
// );
|
||||
|
||||
@@ -21,7 +21,10 @@ class Discord {
|
||||
|
||||
transformData(data) {
|
||||
let siteURL = this.siteData.siteURL;
|
||||
let logo = this.siteData.logo;
|
||||
let logo =
|
||||
this.siteData.siteURL +
|
||||
(!!process.env.KENER_BASE_PATH ? process.env.KENER_BASE_PATH : "") +
|
||||
this.siteData.logo;
|
||||
|
||||
let color = 13250616; //down;
|
||||
if (data.severity === "warning") {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// @ts-nocheck
|
||||
import { Resend } from "resend";
|
||||
import nodemailer from "nodemailer";
|
||||
import getSMTPTransport from "./smtps.js";
|
||||
import { GetRequiredSecrets, ReplaceAllOccurrences } from "../tool.js";
|
||||
|
||||
class Email {
|
||||
to;
|
||||
@@ -7,12 +10,24 @@ class Email {
|
||||
method;
|
||||
siteData;
|
||||
monitorData;
|
||||
meta;
|
||||
|
||||
constructor(meta, siteData, monitorData) {
|
||||
this.to = meta.to;
|
||||
this.from = meta.from;
|
||||
this.siteData = siteData;
|
||||
this.monitorData = monitorData;
|
||||
|
||||
let metaString = JSON.stringify(meta);
|
||||
|
||||
let envSecrets = GetRequiredSecrets(`${JSON.stringify(meta)}`);
|
||||
|
||||
for (let i = 0; i < envSecrets.length; i++) {
|
||||
const secret = envSecrets[i];
|
||||
metaString = ReplaceAllOccurrences(metaString, secret.find, secret.replace);
|
||||
}
|
||||
|
||||
this.meta = JSON.parse(metaString);
|
||||
}
|
||||
|
||||
transformData(data) {
|
||||
@@ -179,13 +194,34 @@ class Email {
|
||||
}
|
||||
|
||||
async send(data) {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
let emailBody = this.transformData(data); // object containing email data (to, subject, text, html, etc)
|
||||
if (!this.meta.email_type || this.meta.email_type === "resend") {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
try {
|
||||
return await resend.emails.send(emailBody);
|
||||
} catch (error) {
|
||||
console.error("Error sending webhook", error);
|
||||
return error;
|
||||
}
|
||||
}
|
||||
if (this.meta.email_type === "smtp") {
|
||||
// Configure the SMTP transporter using environment variables
|
||||
const transporter = getSMTPTransport(this.meta);
|
||||
|
||||
try {
|
||||
return await resend.emails.send(this.transformData(data));
|
||||
} catch (error) {
|
||||
console.error("Error sending webhook", error);
|
||||
return error;
|
||||
const mailOptions = {
|
||||
from: emailBody.from, // sender address
|
||||
to: Array.isArray(emailBody.to) ? emailBody.to.join(",") : emailBody.to, // recipient address(es)
|
||||
subject: emailBody.subject, // email subject
|
||||
text: emailBody.text, // plain text body
|
||||
html: emailBody.html // HTML body (if any)
|
||||
};
|
||||
|
||||
try {
|
||||
return await transporter.sendMail(mailOptions);
|
||||
} catch (error) {
|
||||
console.error("Error sending email via SMTP", error);
|
||||
return error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,7 @@ class Notification {
|
||||
constructor(trigger, siteData, monitorData) {
|
||||
let trigger_meta = JSON.parse(trigger.trigger_meta);
|
||||
if (trigger.trigger_type === "webhook") {
|
||||
this.client = new Webhook(
|
||||
trigger_meta.url,
|
||||
trigger_meta.headers,
|
||||
"POST",
|
||||
siteData,
|
||||
monitorData
|
||||
);
|
||||
this.client = new Webhook(trigger_meta, "POST", siteData, monitorData);
|
||||
} else if (trigger.trigger_type === "discord") {
|
||||
this.client = new Discord(trigger_meta.url, siteData, monitorData);
|
||||
} else if (trigger.trigger_type === "slack") {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// @ts-nocheck
|
||||
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
|
||||
|
||||
let transportId = "smtp_" + HashString(JSON.stringify(meta));
|
||||
|
||||
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;
|
||||
|
||||
return transporter;
|
||||
}
|
||||
@@ -8,14 +8,16 @@ class Webhook {
|
||||
method;
|
||||
siteData;
|
||||
monitorData;
|
||||
trigger_meta;
|
||||
|
||||
constructor(url, headers, method, siteData, monitorData) {
|
||||
constructor(trigger_meta, method, siteData, monitorData) {
|
||||
const kenerHeader = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Kener/3.0.0"
|
||||
};
|
||||
|
||||
this.url = url;
|
||||
let headers = trigger_meta.headers;
|
||||
this.trigger_meta = trigger_meta;
|
||||
this.url = trigger_meta.url;
|
||||
this.headers = kenerHeader;
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
const header = headers[i];
|
||||
@@ -25,19 +27,49 @@ class Webhook {
|
||||
this.siteData = siteData;
|
||||
this.monitorData = monitorData;
|
||||
|
||||
let envSecrets = GetRequiredSecrets(`${this.url} ${JSON.stringify(this.headers)}`);
|
||||
let envSecrets = GetRequiredSecrets(
|
||||
`${this.url} ${JSON.stringify(this.headers)} ${JSON.stringify(this.trigger_meta.webhook_body)}`
|
||||
);
|
||||
//replace secrets in url and headers
|
||||
|
||||
for (let i = 0; i < envSecrets.length; i++) {
|
||||
const secret = envSecrets[i];
|
||||
this.url = ReplaceAllOccurrences(this.url, secret.key, secret.value);
|
||||
this.url = ReplaceAllOccurrences(this.url, secret.find, secret.replace);
|
||||
this.headers = JSON.parse(
|
||||
ReplaceAllOccurrences(JSON.stringify(this.headers), secret.find, secret.replace)
|
||||
);
|
||||
}
|
||||
|
||||
if (!!this.trigger_meta.has_webhook_body && !!this.trigger_meta.webhook_body) {
|
||||
envSecrets = GetRequiredSecrets(this.trigger_meta.webhook_body);
|
||||
for (let i = 0; i < envSecrets.length; i++) {
|
||||
const secret = envSecrets[i];
|
||||
this.trigger_meta.webhook_body = ReplaceAllOccurrences(
|
||||
this.trigger_meta.webhook_body,
|
||||
secret.find,
|
||||
secret.replace
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transformData(data) {
|
||||
return data;
|
||||
if (!!!this.trigger_meta.has_webhook_body) return JSON.stringify(data);
|
||||
if (!!!this.trigger_meta.webhook_body) return JSON.stringify(data);
|
||||
let body = this.trigger_meta.webhook_body;
|
||||
body = ReplaceAllOccurrences(body, "${id}", data.id);
|
||||
body = ReplaceAllOccurrences(body, "${alert_name}", data.alert_name);
|
||||
body = ReplaceAllOccurrences(body, "${severity}", data.severity);
|
||||
body = ReplaceAllOccurrences(body, "${status}", data.status);
|
||||
body = ReplaceAllOccurrences(body, "${source}", data.source);
|
||||
body = ReplaceAllOccurrences(body, "${timestamp}", data.timestamp);
|
||||
body = ReplaceAllOccurrences(body, "${description}", data.description);
|
||||
body = ReplaceAllOccurrences(body, "${metric}", data.details.metric);
|
||||
body = ReplaceAllOccurrences(body, "${current_value}", data.details.current_value);
|
||||
body = ReplaceAllOccurrences(body, "${threshold}", data.details.threshold);
|
||||
body = ReplaceAllOccurrences(body, "${action_text}", data.actions[0].text);
|
||||
body = ReplaceAllOccurrences(body, "${action_url}", data.actions[0].url);
|
||||
return body;
|
||||
}
|
||||
|
||||
type() {
|
||||
@@ -49,7 +81,7 @@ class Webhook {
|
||||
const response = await fetch(this.url, {
|
||||
method: this.method,
|
||||
headers: this.headers,
|
||||
body: JSON.stringify(this.transformData(data))
|
||||
body: this.transformData(data)
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
|
||||
+71
-58
@@ -8,17 +8,22 @@ import {
|
||||
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
|
||||
} from "$lib/server/controllers/controller.js";
|
||||
|
||||
import { GetDataGroupByDayAlternative } from "$lib/server/controllers/controller.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)
|
||||
});
|
||||
|
||||
function getSummaryDuration(numOfMinute) {
|
||||
if (numOfMinute > 59) {
|
||||
let hour = Math.floor(numOfMinute / 60);
|
||||
let minute = numOfMinute % 60;
|
||||
return `${hour} hours ${minute} minute${minute > 1 ? "s" : ""}`;
|
||||
} else {
|
||||
return `${numOfMinute} minute${numOfMinute > 1 ? "s" : ""}`;
|
||||
}
|
||||
return fdm(duration, selectedLang);
|
||||
}
|
||||
function getTimezoneOffset(timeZone) {
|
||||
const formatter = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "short" });
|
||||
@@ -32,11 +37,12 @@ function getTimezoneOffset(timeZone) {
|
||||
return parseInt(hours) * 60 + parseInt(minutes);
|
||||
}
|
||||
|
||||
function returnStatusClass(val, c, barStyle) {
|
||||
function returnStatusClass(val, total, c, barStyle) {
|
||||
// return "api-degraded-10";
|
||||
if (barStyle === undefined || barStyle == "FULL") {
|
||||
return c;
|
||||
} else if (barStyle == "PARTIAL") {
|
||||
let totalHeight = 24 * 60;
|
||||
let totalHeight = total;
|
||||
let cl = `api-up`;
|
||||
if (val > 0 && val <= 0.1 * totalHeight) {
|
||||
cl = c + "-10";
|
||||
@@ -70,11 +76,9 @@ function getCountOfSimilarStatuesEnd(arr, statusType) {
|
||||
return count;
|
||||
}
|
||||
|
||||
const FetchData = async function (site, monitor, localTz) {
|
||||
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;
|
||||
@@ -83,24 +87,6 @@ const FetchData = async function (site, monitor, localTz) {
|
||||
let offsetInMinutes = parseInt((GetDayStartTimestampUTC(now) - midnight) / 60);
|
||||
const _90Day = {};
|
||||
let latestTimestamp = 0;
|
||||
let ij = 0;
|
||||
for (let i = midnight90DaysAgo; i < midnightTomorrow; i += secondsInDay) {
|
||||
_90Day[i] = {
|
||||
UP: 0,
|
||||
DEGRADED: 0,
|
||||
DOWN: 0,
|
||||
timestamp: i,
|
||||
cssClass: StatusObj.NO_DATA,
|
||||
textClass: StatusObj.NO_DATA,
|
||||
summaryDuration: 0,
|
||||
summaryStatus: NO_DATA,
|
||||
message: NO_DATA,
|
||||
border: true,
|
||||
ij: ij
|
||||
};
|
||||
ij++;
|
||||
latestTimestamp = i;
|
||||
}
|
||||
|
||||
let dbData = await GetDataGroupByDayAlternative(
|
||||
monitor.tag,
|
||||
@@ -112,40 +98,51 @@ const FetchData = async function (site, monitor, localTz) {
|
||||
let totalDownCount = 0;
|
||||
let totalUpCount = 0;
|
||||
|
||||
let summaryDuration = 0;
|
||||
let summaryStatus = "UP";
|
||||
|
||||
let summaryColorClass = "api-nodata";
|
||||
for (let i = 0; i < dbData.length; i++) {
|
||||
let dayData = dbData[i];
|
||||
let ts = dayData.timestamp;
|
||||
let cssClass = StatusObj.UP;
|
||||
let message = "Status OK";
|
||||
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, StatusObj.DEGRADED, site.barStyle);
|
||||
summaryDuration = getSummaryDuration(dayData.DEGRADED);
|
||||
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, StatusObj.DOWN, site.barStyle);
|
||||
summaryDuration = getSummaryDuration(dayData.DOWN);
|
||||
cssClass = returnStatusClass(
|
||||
dayData.DOWN,
|
||||
dayData.total,
|
||||
StatusObj.DOWN,
|
||||
site.barStyle
|
||||
);
|
||||
|
||||
summaryDuration = getSummaryDuration(dayData.DOWN, selectedLang);
|
||||
summaryStatus = "DOWN";
|
||||
}
|
||||
|
||||
if (!!_90Day[ts]) {
|
||||
_90Day[ts].timestamp = ts;
|
||||
_90Day[ts].cssClass = cssClass;
|
||||
_90Day[ts].summaryDuration = summaryDuration;
|
||||
_90Day[ts].summaryStatus = summaryStatus;
|
||||
_90Day[ts].textClass = cssClass.replace(/-\d+$/, "");
|
||||
}
|
||||
_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].textClass = cssClass.replace(/-\d+$/, "");
|
||||
}
|
||||
let uptime90DayNumerator = totalUpCount + totalDegradedCount;
|
||||
let uptime90DayDenominator = totalUpCount + totalDownCount + totalDegradedCount;
|
||||
@@ -156,29 +153,46 @@ const FetchData = async function (site, monitor, localTz) {
|
||||
}
|
||||
// return _90Day;
|
||||
let uptime90Day = ParseUptime(uptime90DayNumerator, uptime90DayDenominator);
|
||||
if (site.summaryStyle === "CURRENT") {
|
||||
let todayDataDb = await db.getMonitoringData(
|
||||
monitor.tag,
|
||||
latestTimestamp,
|
||||
latestTimestamp + secondsInDay
|
||||
);
|
||||
|
||||
let summaryDuration = 0;
|
||||
let summaryStatus = "UP";
|
||||
|
||||
let summaryColorClass = "api-nodata";
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
if (site.summaryStyle === "CURRENT") {
|
||||
summaryColorClass = "api-up";
|
||||
|
||||
let lastRow = todayDataDb[todayDataDb.length - 1];
|
||||
|
||||
if (!!lastRow && lastRow.status == "DEGRADED") {
|
||||
summaryDuration = getSummaryDuration(
|
||||
getCountOfSimilarStatuesEnd(todayDataDb, "DEGRADED")
|
||||
getCountOfSimilarStatuesEnd(todayDataDb, "DEGRADED"),
|
||||
selectedLang
|
||||
);
|
||||
summaryStatus = "DEGRADED";
|
||||
summaryColorClass = "api-degraded";
|
||||
}
|
||||
if (!!lastRow && lastRow.status == "DOWN") {
|
||||
summaryDuration = getSummaryDuration(getCountOfSimilarStatuesEnd(todayDataDb, "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+$/, "");
|
||||
@@ -188,7 +202,6 @@ const FetchData = async function (site, monitor, localTz) {
|
||||
return {
|
||||
_90Day: _90Day,
|
||||
uptime90Day: uptime90Day,
|
||||
summaryDuration: summaryDuration,
|
||||
summaryStatus: summaryStatus,
|
||||
summaryColorClass: summaryColorClass,
|
||||
barRoundness: site.barRoundness,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// @ts-nocheck
|
||||
import net from "net"; // Use import instead of require
|
||||
import ping from "ping";
|
||||
|
||||
const TCP = function (type, host, port, timeout) {
|
||||
port = parseInt(port, 10);
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
const start = process.hrtime.bigint(); // High-precision timestamp
|
||||
let resolved = false;
|
||||
|
||||
const onFinish = (status) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
const end = process.hrtime.bigint();
|
||||
const latency = Number(end - start) / 1e6; // Convert nanoseconds to milliseconds
|
||||
socket.destroy();
|
||||
resolve({ status, latency, host, port, type });
|
||||
}
|
||||
};
|
||||
|
||||
socket.setTimeout(timeout);
|
||||
socket.once("connect", () => onFinish("open"));
|
||||
socket.once("timeout", () => onFinish("timeout"));
|
||||
socket.once("error", () => onFinish("error"));
|
||||
|
||||
// Check if it's an IPv6 address (contains ':')
|
||||
const options = {
|
||||
host,
|
||||
port,
|
||||
family: type === "IP6" ? 6 : 4
|
||||
}; //host.includes(":") ? { host, port, family: 6 } : { host, port };
|
||||
socket.connect(options);
|
||||
});
|
||||
};
|
||||
|
||||
const Ping = async function (type, host, timeout, count) {
|
||||
let output = {
|
||||
alive: false,
|
||||
min: null,
|
||||
max: null,
|
||||
avg: null,
|
||||
latencies: [],
|
||||
latency: null,
|
||||
host: host,
|
||||
type: type
|
||||
};
|
||||
|
||||
try {
|
||||
let res = await ping.promise.probe(host, {
|
||||
v6: type === "IP6",
|
||||
timeout: type === "IP6" ? false : Math.floor(timeout / 1000),
|
||||
min_reply: count
|
||||
});
|
||||
output.alive = res.alive;
|
||||
output.min = res.min;
|
||||
output.max = res.max;
|
||||
output.avg = res.avg;
|
||||
output.latencies = res.times;
|
||||
output.latency = res.time;
|
||||
} catch (error) {
|
||||
console.log(`Error in pingCall IP4 for ${host}`, error);
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} input
|
||||
*/
|
||||
function ExtractIPv6HostAndPort(input) {
|
||||
const parts = input.split(":"); // Split by colons
|
||||
|
||||
// If there's a valid port at the end, extract it
|
||||
const lastPart = parts[parts.length - 1];
|
||||
const port = /^\d+$/.test(lastPart) ? parseInt(parts.pop(), 10) : null; // Check if last part is a number
|
||||
|
||||
// Reconstruct the IPv6 address
|
||||
const host = parts.join(":");
|
||||
|
||||
// Ensure it's a valid IPv6 format
|
||||
if (host.includes(":")) {
|
||||
return { host, port }; // Port may be null if not present
|
||||
}
|
||||
return null; // Return null if the format is incorrect
|
||||
}
|
||||
|
||||
export { TCP, ExtractIPv6HostAndPort, Ping };
|
||||
@@ -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,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,31 @@
|
||||
// @ts-nocheck
|
||||
import ApiCall from "./apiCall.js";
|
||||
import PingCall from "./pingCall.js";
|
||||
import TcpCall from "./tcpCall.js";
|
||||
import DnsCall from "./dnsCall.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 {
|
||||
console.log("Invalid monitor.monitor_type ", monitor.monitor_type);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
async execute() {
|
||||
return await this.service.execute();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
+23
-1
@@ -108,6 +108,17 @@ const BeginningOfDay = (options = {}) => {
|
||||
);
|
||||
return dt.getTime() / 1000;
|
||||
};
|
||||
const BeginningOfMinute = (options = {}) => {
|
||||
const { date = new Date(), timeZone } = options;
|
||||
const parts = Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
hourCycle: "h23",
|
||||
second: "numeric"
|
||||
}).formatToParts(date);
|
||||
const second = parseInt(parts.find((i) => i.type === "second").value);
|
||||
const dt = new Date(1000 * Math.floor((date - second * 1000) / 1000));
|
||||
return dt.getTime() / 1000;
|
||||
};
|
||||
const ValidateIpAddress = function (input) {
|
||||
// Check if input is a valid IPv4 address
|
||||
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
@@ -276,6 +287,14 @@ function GenerateRandomColor() {
|
||||
var randomColor = Math.floor(Math.random() * 16777215).toString(16);
|
||||
return randomColor;
|
||||
}
|
||||
|
||||
//wait for x ms promise
|
||||
function Wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
function MaskString(str) {
|
||||
return "*".repeat(str.length - 4) + str.slice(-4);
|
||||
}
|
||||
export {
|
||||
IsValidURL,
|
||||
IsValidHTTPMethod,
|
||||
@@ -299,5 +318,8 @@ export {
|
||||
ReplaceAllOccurrences,
|
||||
GetRequiredSecrets,
|
||||
ValidateMonitorAlerts,
|
||||
GenerateRandomColor
|
||||
GenerateRandomColor,
|
||||
BeginningOfMinute,
|
||||
Wait,
|
||||
MaskString
|
||||
};
|
||||
|
||||
+35
-31
@@ -1,90 +1,94 @@
|
||||
.tabs [data-state="active"] {
|
||||
border-color: orange;
|
||||
border-color: orange;
|
||||
}
|
||||
.copybtn .copy-btn {
|
||||
transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
.copybtn .check-btn {
|
||||
transform: scale(0);
|
||||
transform: scale(0);
|
||||
}
|
||||
.copybtn:focus .copy-btn {
|
||||
transform: scale(0);
|
||||
transform: scale(0);
|
||||
}
|
||||
.copybtn:focus .check-btn {
|
||||
transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
input,
|
||||
[data-bits-select-trigger],
|
||||
textarea {
|
||||
background-color: rgba(0, 0, 0, 0.02) !important;
|
||||
background-color: rgba(0, 0, 0, 0.02) !important;
|
||||
}
|
||||
.dark input,
|
||||
.dark [data-bits-select-trigger],
|
||||
.dark textarea {
|
||||
background-color: rgba(0, 0, 0, 0.1) !important;
|
||||
background-color: rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
input[type="file"]::file-selector-button {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
input[type="file"] {
|
||||
font-weight: 500;
|
||||
padding-top: 10px;
|
||||
font-weight: 500;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.image-upload .has-bg:hover .invisible {
|
||||
visibility: visible !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
[aria-label="color picker"] input {
|
||||
color: rgba(0, 0, 0, 0.5) !important;
|
||||
color: rgba(0, 0, 0, 0.5) !important;
|
||||
}
|
||||
#start_date_time,
|
||||
#end_date_time,
|
||||
#newcomment_commented_at {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
height: 40px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
height: 40px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#start_date_time::placeholder,
|
||||
#end_date_time::placeholder {
|
||||
color: hsl(219, 14%, 47%);
|
||||
color: hsl(219, 14%, 47%);
|
||||
}
|
||||
.dark #start_date_time::placeholder,
|
||||
.dark #end_date_time::placeholder {
|
||||
color: hsl(0, 0%, 40%);
|
||||
color: hsl(0, 0%, 40%);
|
||||
}
|
||||
|
||||
.bg-hover {
|
||||
background-image: linear-gradient(to right, #d0f5be, #d0f5be);
|
||||
background-size: 25%;
|
||||
background-repeat: no-repeat;
|
||||
transition: background 0.3s;
|
||||
background-image: linear-gradient(to right, #d0f5be, #d0f5be);
|
||||
background-size: 25%;
|
||||
background-repeat: no-repeat;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.dark .bg-hover {
|
||||
background-image: linear-gradient(to right, #6f61c0, #6f61c0);
|
||||
background-image: linear-gradient(to right, #6f61c0, #6f61c0);
|
||||
}
|
||||
.bg-hover:has(div:first-child:hover),
|
||||
.bg-hover.state-INVESTIGATING {
|
||||
background-size: 25%;
|
||||
background-size: 25%;
|
||||
}
|
||||
.bg-hover:has(div:nth-child(2):hover),
|
||||
.bg-hover.state-IDENTIFIED {
|
||||
background-size: 50%;
|
||||
background-size: 50%;
|
||||
}
|
||||
.bg-hover:has(div:nth-child(3):hover),
|
||||
.bg-hover.state-MONITORING {
|
||||
background-size: 75%;
|
||||
background-size: 75%;
|
||||
}
|
||||
.bg-hover:has(div:nth-child(4):hover),
|
||||
.bg-hover.state-RESOLVED {
|
||||
background-size: 100%;
|
||||
background-size: 100%;
|
||||
}
|
||||
|
||||
.no-scroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -4,49 +4,55 @@ import path from "path";
|
||||
import { marked } from "marked";
|
||||
|
||||
function extractMetadataAndContent(fileContent) {
|
||||
// Regular expression to match metadata section
|
||||
const metadataRegex = /^---\s*([\s\S]+?)\s*---\s*/;
|
||||
const match = fileContent.match(metadataRegex);
|
||||
// Regular expression to match metadata section
|
||||
const metadataRegex = /^---\s*([\s\S]+?)\s*---\s*/;
|
||||
const match = fileContent.match(metadataRegex);
|
||||
|
||||
const result = {
|
||||
metadata: {},
|
||||
content: fileContent // Default to entire content if no metadata is found
|
||||
};
|
||||
const result = {
|
||||
metadata: {},
|
||||
content: fileContent, // Default to entire content if no metadata is found
|
||||
};
|
||||
|
||||
if (match) {
|
||||
// Extract metadata section and remaining content
|
||||
const metadataLines = match[1].split("\n").filter((line) => line.trim() !== "");
|
||||
result.content = fileContent.slice(match[0].length).trim(); // Remaining content after metadata
|
||||
if (match) {
|
||||
// Extract metadata section and remaining content
|
||||
const metadataLines = match[1]
|
||||
.split("\n")
|
||||
.filter((line) => line.trim() !== "");
|
||||
result.content = fileContent.slice(match[0].length).trim(); // Remaining content after metadata
|
||||
|
||||
metadataLines.forEach((line) => {
|
||||
const [key, value] = line.split(":").map((part) => part.trim());
|
||||
if (key && value) {
|
||||
result.metadata[key.toLowerCase()] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
metadataLines.forEach((line) => {
|
||||
const [key, value] = line.split(":").map((part) => part.trim());
|
||||
if (key && value) {
|
||||
result.metadata[key.toLowerCase()] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
return result;
|
||||
}
|
||||
export async function load({ params, route, url, cookies, request }) {
|
||||
const docFile = params.doc;
|
||||
const docFile = params.doc;
|
||||
//get all the query params
|
||||
|
||||
const docFolderPath = path.resolve("docs");
|
||||
let docFilePath = docFolderPath + "/home.md";
|
||||
if (docFile) {
|
||||
docFilePath = docFolderPath + `/${docFile}.md`;
|
||||
}
|
||||
const fileContents = await fs.readFileSync(docFilePath, "utf-8");
|
||||
const siteStructure = await fs.readFileSync(docFolderPath + "/structure.json", "utf-8");
|
||||
const { metadata, content } = extractMetadataAndContent(fileContents);
|
||||
const docFolderPath = path.resolve("docs");
|
||||
let docFilePath = docFolderPath + "/home.md";
|
||||
if (docFile) {
|
||||
docFilePath = docFolderPath + `/${docFile}.md`;
|
||||
}
|
||||
const fileContents = await fs.readFileSync(docFilePath, "utf-8");
|
||||
const siteStructure = await fs.readFileSync(
|
||||
docFolderPath + "/structure.json",
|
||||
"utf-8",
|
||||
);
|
||||
const { metadata, content } = extractMetadataAndContent(fileContents);
|
||||
|
||||
const selectedDoc = docFilePath.replace(docFolderPath, "");
|
||||
const siteStructureJSON = JSON.parse(siteStructure);
|
||||
const selectedDoc = docFilePath.replace(docFolderPath, "");
|
||||
const siteStructureJSON = JSON.parse(siteStructure);
|
||||
|
||||
return {
|
||||
md: marked.parse(content),
|
||||
title: metadata.title || "Kener Docs",
|
||||
description: metadata.description || "Kener Docs",
|
||||
siteStructure: siteStructureJSON
|
||||
};
|
||||
return {
|
||||
md: marked.parse(content),
|
||||
title: metadata.title || "Kener Docs",
|
||||
description: metadata.description || "Kener Docs",
|
||||
siteStructure: siteStructureJSON,
|
||||
};
|
||||
}
|
||||
|
||||
+154
-176
@@ -1,194 +1,172 @@
|
||||
<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 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");
|
||||
}
|
||||
function setTheme() {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
|
||||
function activateSidebar(selectedDoc) {
|
||||
for (let i = 0; i < sidebar.length; i++) {
|
||||
const item = sidebar[i];
|
||||
for (let j = 0; j < item.children.length; j++) {
|
||||
const subItem = item.children[j];
|
||||
if (subItem.file == selectedDoc) {
|
||||
subItem.active = true;
|
||||
sidebar[i].children[j].active = true;
|
||||
} else {
|
||||
subItem.active = false;
|
||||
sidebar[i].children[j].active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function activateSidebar(selectedDoc) {
|
||||
for (let i = 0; i < sidebar.length; i++) {
|
||||
const item = sidebar[i];
|
||||
for (let j = 0; j < item.children.length; j++) {
|
||||
const subItem = item.children[j];
|
||||
if (subItem.file == selectedDoc) {
|
||||
subItem.active = true;
|
||||
sidebar[i].children[j].active = true;
|
||||
} else {
|
||||
subItem.active = false;
|
||||
sidebar[i].children[j].active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pageChange(e) {
|
||||
if (e.detail.docFilePath) {
|
||||
activateSidebar(e.detail.docFilePath);
|
||||
}
|
||||
}
|
||||
function updateTableOfContents(e) {
|
||||
if (e.detail.rightbar) {
|
||||
tableOfContents = e.detail.rightbar;
|
||||
}
|
||||
}
|
||||
function pageChange(e) {
|
||||
if (e.detail.docFilePath) {
|
||||
activateSidebar(e.detail.docFilePath);
|
||||
}
|
||||
}
|
||||
function updateTableOfContents(e) {
|
||||
if (e.detail.rightbar) {
|
||||
tableOfContents = e.detail.rightbar;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
setTheme();
|
||||
});
|
||||
onMount(() => {
|
||||
setTheme();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window on:pagechange={pageChange} on:rightbar={updateTableOfContents} />
|
||||
<svelte:head>
|
||||
<link rel="icon" id="kener-app-favicon" href="{base}/logo96.png" />
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-Q3MLRXCBFT"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag("js", new Date());
|
||||
<link rel="icon" id="kener-app-favicon" href="{base}/logo96.png" />
|
||||
|
||||
gtag("config", "G-Q3MLRXCBFT");
|
||||
</script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/atom-one-dark.min.css"
|
||||
/>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-Q3MLRXCBFT"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag("js", new Date());
|
||||
|
||||
<!-- Highlight.js JS -->
|
||||
<script
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"
|
||||
></script>
|
||||
gtag("config", "G-Q3MLRXCBFT");
|
||||
</script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/atom-one-dark.min.css"
|
||||
/>
|
||||
|
||||
<!-- Highlight.js JS -->
|
||||
<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">
|
||||
<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 -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<a href="/" class="flex items-center space-x-3">
|
||||
<!-- 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">
|
||||
v3.0.0
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<nav class="z-2 fixed left-0 right-0 top-0 z-30 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 -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<a href="/" class="flex items-center space-x-3">
|
||||
<!-- 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.1.0 </span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<div class="hidden md:block">
|
||||
<div class="flex items-center space-x-8">
|
||||
<a href="https://github.com/rajnandan1/kener" class="text-sm font-medium">
|
||||
<img
|
||||
alt="GitHub Repo stars"
|
||||
src="https://img.shields.io/github/stars/rajnandan1/kener?label=Star%20Repo&style=social"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Navigation Links -->
|
||||
<div class="hidden md:block">
|
||||
<div class="flex items-center space-x-8">
|
||||
<a href="https://github.com/rajnandan1/kener" class="text-sm font-medium">
|
||||
<img
|
||||
alt="GitHub Repo stars"
|
||||
src="https://img.shields.io/github/stars/rajnandan1/kener?label=Star%20Repo&style=social"
|
||||
/>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<!-- 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</nav>
|
||||
</aside>
|
||||
<!-- 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}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 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">
|
||||
<!-- 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"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
<h4 class="mb-3 text-sm font-semibold uppercase tracking-wider">On this page</h4>
|
||||
<nav class="space-y-2">
|
||||
{#each tableOfContents as item}
|
||||
<a
|
||||
href="#{item.id}"
|
||||
class="block overflow-hidden text-ellipsis whitespace-nowrap text-xs text-muted-foreground hover:underline {item.level ==
|
||||
3
|
||||
? 'ml-4'
|
||||
: ''}"
|
||||
>
|
||||
{item.text}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
{/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">
|
||||
<!-- 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"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</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">
|
||||
<h4 class="mb-3 text-sm font-semibold uppercase tracking-wider">On this page</h4>
|
||||
<nav class="space-y-2">
|
||||
{#each tableOfContents as item}
|
||||
<a
|
||||
href="#{item.id}"
|
||||
class="block overflow-hidden text-ellipsis whitespace-nowrap text-xs text-muted-foreground hover:underline {item.level ==
|
||||
3
|
||||
? 'ml-4'
|
||||
: ''}"
|
||||
>
|
||||
{item.text}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import { base } from "$app/paths";
|
||||
|
||||
export async function load({ parent, url }) {
|
||||
throw redirect(302, base + "/docs/home");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user