Compare commits

..

83 Commits

Author SHA1 Message Date
Raj Nandan Sharma b56d20ac35 Merge pull request #367 from rajnandan1/release/3.2.12
feat: add kenerTheme support and enhance footer HTML structure
2025-03-31 17:17:30 +05:30
Raj Nandan Sharma 9ee7a7b861 feat: add kenerTheme support and enhance footer HTML structure 2025-03-31 17:12:40 +05:30
Raj Nandan Sharma f73ffce3f0 feat: enhance documentation and styling for custom JS/CSS guide and incident comments 2025-03-30 12:58:19 +05:30
Raj Nandan Sharma 0997967787 docs: update environment variables documentation for CORS and reverse proxy setup
feat: modify analytics event naming convention for consistency

fix: adjust delete monitor confirmation form layout for better UX

style: refine positioning of monitor action buttons for improved alignment

refactor: streamline password validation imports in setup and forgot password routes

fix: remove unnecessary line breaks in layout server file
2025-03-29 22:33:17 +05:30
github-actions 3476ec2b29 Auto-generate README.md with release versions 2025-03-28 16:39:18 +00:00
Raj Nandan Sharma e0cf568aa9 feat: implement custom handle function to modify response headers 2025-03-28 21:50:07 +05:30
Raj Nandan Sharma c12d7f8345 feat: add null check for document before dispatching analytics event 2025-03-28 21:18:26 +05:30
Raj Nandan Sharma 9ba115d5ab Merge pull request #366 from rajnandan1/release/3.2.11
release 3.2.11
2025-03-28 20:56:59 +05:30
Raj Nandan Sharma 3756bbfd06 chore: bump version to 3.2.11 in package.json 2025-03-28 20:54:50 +05:30
Raj Nandan Sharma 9096bcc641 feat: import analytics event module for enhanced tracking in incident page 2025-03-28 20:53:44 +05:30
Raj Nandan Sharma c4d04854e5 feat: enhance monitor deletion confirmation UI and add analytics event for incident back button click 2025-03-28 20:52:59 +05:30
Raj Nandan Sharma d95fc0005b feat: indicate Markdown support in incident summary and comment sections 2025-03-28 20:40:24 +05:30
Raj Nandan Sharma 2b9093184a feat: add support for Markdown language in events and enhance SEO documentation as requested in #359 2025-03-28 20:34:19 +05:30
Raj Nandan Sharma 831376054b feat: add analytics capture snippets and enhance event tracking also added plausible and ms clarity #355 2025-03-28 15:07:45 +05:30
Raj Nandan Sharma 885ee0b6d6 feat: implement monitor deletion functionality with confirmation 2025-03-26 11:47:53 +05:30
Raj Nandan Sharma 511d0f26dd feat: add email validation in CreateUpdateTrigger function 2025-03-26 10:16:36 +05:30
github-actions 5263698625 Auto-generate README.md with release versions 2025-03-25 17:45:05 +00:00
Raj Nandan Sharma 7859f9564e fix: bump version to 3.2.10 in package.json 2025-03-25 22:58:04 +05:30
github-actions 149456682d Auto-generate README.md with release versions 2025-03-25 17:20:55 +00:00
Raj Nandan Sharma 8c040997da fix: refactor monitorTriggers initialization into a separate function 2025-03-25 22:47:05 +05:30
Raj Nandan Sharma 868fed0176 fix: update tzdata version in Dockerfile to use latest available 2025-03-25 22:38:13 +05:30
Raj Nandan Sharma c7f44a48da fix: bump version to 3.2.9 in package.json 2025-03-25 22:31:57 +05:30
Raj Nandan Sharma 6300bff4e7 Merge pull request #363 from rajnandan1/fix/357
fix: update RBAC documentation and improve monitor data retrieval log…
2025-03-25 22:27:04 +05:30
Raj Nandan Sharma 3e6b1ffdbb fix: streamline query execution in DbImpl by removing redundant return statement 2025-03-25 22:24:10 +05:30
Raj Nandan Sharma 408fd5318b fix: update RBAC documentation and improve monitor data retrieval logic, fixes #357 and #361 2025-03-25 22:20:45 +05:30
Raj Nandan Sharma cfe419f6df Merge pull request #362 from rajnandan1/fix/360
Fix/360
2025-03-25 20:39:07 +05:30
Raj Nandan Sharma 5403726099 fix: replace parseInt with Number for timeout conversion in monitorSheet.svelte and ping.js 2025-03-25 20:38:23 +05:30
Raj Nandan Sharma 057c14909f fix #360 2025-03-25 20:29:30 +05:30
Raj Nandan Sharma 37b18c06bb fix: validate timeout input and ensure it's a number in monitorSheet.svelte 2025-03-25 20:28:39 +05:30
github-actions 3ad1eb9a03 Auto-generate README.md with release versions 2025-03-24 15:18:34 +00:00
Raj Nandan Sharma a1064771f8 fix: increment package version from 3.2.7 to 3.2.8 in package.json 2025-03-24 20:34:24 +05:30
Raj Nandan Sharma 463ee286bf fix: increment package version from 3.2.6 to 3.2.7 in package.json 2025-03-24 20:30:05 +05:30
Raj Nandan Sharma a7ae6e72ef fix: downgrade package version from 3.2.8 to 3.2.6 in package.json 2025-03-24 20:28:01 +05:30
github-actions 36c67f60dd Auto-generate README.md with release versions 2025-03-24 14:56:20 +00:00
Raj Nandan Sharma bb718b04a6 fix: update Dockerfile to use ARG variables for Node.js version tags 2025-03-24 20:10:13 +05:30
Raj Nandan Sharma b6ea060054 fix: update publish-images workflow and increment package version to 3.2.8 2025-03-24 20:01:53 +05:30
Raj Nandan Sharma 5dba54c048 fix: update publish-images workflow to set GH_TOKEN environment variable correctly 2025-03-24 19:53:29 +05:30
Raj Nandan Sharma 415276163f Merge pull request #356 from rajnandan1/release/3.2.8-x
fix: update Node.js version to 23 and increment package version to 3.2.7
2025-03-24 19:45:44 +05:30
Raj Nandan Sharma 017b9e2e84 fix: update Node.js version to 23 and increment package version to 3.2.7 2025-03-24 19:45:15 +05:30
Raj Nandan Sharma 5740991ee1 fix: update GitHub Actions workflow to use GH_PAT instead of GITHUB_TOKEN for authentication 2025-03-24 15:12:40 +05:30
Raj Nandan Sharma 06008bd719 fix: streamline Dockerfile by removing unnecessary paths and using variable references for base images 2025-03-24 12:11:03 +05:30
Raj Nandan Sharma b53cd485e5 Merge pull request #263 from kaffolder7/feature/dependabot-version-updates
feat: automate dependency updates 🤖
2025-03-24 11:59:41 +05:30
Raj Nandan Sharma 7c1fd39158 Merge branch 'main' into feature/dependabot-version-updates 2025-03-24 11:58:58 +05:30
Raj Nandan Sharma f7888f1ba4 fix: update package versions in Dockerfile for consistency and stability 2025-03-24 11:32:05 +05:30
Raj Nandan Sharma ece5ac37ad feat: update embed monitor URLs to use siteURL and base variables for improved flexibility 2025-03-24 11:19:51 +05:30
Raj Nandan Sharma a51183fb23 Merge pull request #348 from rajnandan1/rbac
Features role-based access control and user management
2025-03-24 10:45:39 +05:30
Raj Nandan Sharma 7875192fb0 feat: update package dependencies and improve invitation expiry date formatting 2025-03-24 10:17:21 +05:30
Raj Nandan Sharma eb128ad431 feat: add "View in detail" button for group monitors and update localization files 2025-03-23 22:44:11 +05:30
Raj Nandan Sharma ba61ed87ab feat: enhance incident month handling with improved date calculations and formatting, fixing #349 2025-03-23 21:51:51 +05:30
Raj Nandan Sharma 8b5348081d docs: update changelogs to include improvements in build time and component migration 2025-03-23 14:29:15 +05:30
Raj Nandan Sharma bb7e69ace5 fix: update icon imports to use specific paths from lucide-svelte 2025-03-23 14:28:04 +05:30
Raj Nandan Sharma 3b8bb60423 fix: update print width in configuration files and improve documentation links 2025-03-23 13:36:22 +05:30
Raj Nandan Sharma 93339ce9df feat: add support for self-signed certificates and update changelog, fixes #351 2025-03-23 12:32:36 +05:30
Raj Nandan Sharma d30d99d8c2 docs: update changelogs to include resolved GitHub issues and improve formatting 2025-03-22 22:57:26 +05:30
Raj Nandan Sharma 3468df16e1 fix: improve error handling and code structure in Minuter function #345 2025-03-22 22:50:17 +05:30
Raj Nandan Sharma ccdeff98dc feat: implement dynamic versioning in headers and documentation as reported in #346 2025-03-22 21:54:24 +05:30
Raj Nandan Sharma 7e182b1df2 fix: replace showModal with closeModal for better modal handling #350 2025-03-22 21:06:37 +05:30
Raj Nandan Sharma ff652ad435 fix: #238 #210 #309 #334 2025-03-22 16:42:21 +05:30
Raj Nandan Sharma 1fdafa7966 Adds badges and embed features
Implements badges for monitors, including status, uptime, and liveness, along with a dedicated management page.

Adds embed options for various platforms with customizable styles.
2025-03-22 16:36:19 +05:30
Raj Nandan Sharma 1f5e683af5 feat: integrate CodeMirror editor for enhanced JavaScript and JSON editing experience 2025-03-20 23:23:54 +05:30
Raj Nandan Sharma db8c881571 refactor: remove performance logging and clean up code in various files 2025-03-19 19:45:12 +05:30
Raj Nandan Sharma 14f49c5b3b Features role-based access control and user management
Implements role-based access control with admin, editor, and member roles.

Introduces a user management system with profiles, activation/deactivation, and password reset.

Adds an email authentication system with verification and password reset via email.

Includes an invitation system with token-based invitations and admin controls.

Improves performance, security, UI, and developer experience.

Updates package version to 3.2.5 and adds vite-plugin-package-version.

Removes libcap related code from Dockerfile.
2025-03-18 23:08:51 +05:30
Raj Nandan Sharma 52286f26a5 fix: update version to 3.2.5 and reflect changes in User-Agent and documentation 2025-03-13 09:55:04 +05:30
github-actions 45e7567cb6 Auto-generate README.md with release versions 2025-03-12 17:48:41 +00:00
Raj Nandan Sharma c0edb59884 chore: update version to 3.2.4 and reflect changes in User-Agent and documentation 2025-03-12 23:16:48 +05:30
Raj Nandan Sharma 1e86c429ca fix: install libcap tools for setting capabilities in Dockerfile 2025-03-12 23:00:18 +05:30
Raj Nandan Sharma fbaebdcbcf Merge pull request #344 from rajnandan1/fix-ping-non-root
refactor: clean up Dockerfile and improve security practices
2025-03-12 22:49:47 +05:30
Raj Nandan Sharma 3f9716b0a4 fix: update donation links from GitHub Sponsors to Buy Me a Coffee 2025-03-12 22:48:53 +05:30
Raj Nandan Sharma 57d32197cf chore: update version to 3.2.3 and reflect changes in documentation and headers 2025-03-12 22:05:25 +05:30
Raj Nandan Sharma 9e6a3f26c2 refactor: clean up Dockerfile and improve security practices 2025-03-12 21:30:06 +05:30
github-actions 23314beb15 Auto-generate README.md with release versions 2025-03-08 18:58:59 +00:00
Raj Nandan Sharma 64b94da585 Merge pull request #340 from rajnandan1/release/3.2.2
Release/3.2.2
2025-03-08 22:57:28 +05:30
Kyle Affolder 8c95c94472 update: combine to avoid multiple processes
Combined to reduce spawning multiple `rm` processes.
2025-02-17 13:29:34 -05:00
Kyle Affolder 7dafb2eddc add: GHA job to confirm if Dependabot PRs exist
Adds job to check if any Dependabot PRs are open and if so, fail the Docker build (since we need to ensure OS packages exist and are in their correct versions when using pinned versions for security purposes).
2025-02-17 13:24:22 -05:00
Kyle Affolder 7c9f3eb87f add: add back pinned tzdata version
Necessary for Dependabot to track
2025-02-17 13:22:15 -05:00
Kyle ab34dd81f8 Merge branch 'rajnandan1:main' into feature/dependabot-version-updates 2025-02-17 13:04:23 -05:00
Kyle Affolder a8be878a87 update: commented out node.js deps. monitoring
Not sure if we are wanting Dependabot to track Node.js packages, so for the time being, commenting this block out, but leaving for now w/ “TODO” to come back to at a later point.
2025-02-11 17:54:37 -05:00
Kyle Affolder 2b1849f1a3 update: grammar updates 2025-02-11 17:47:45 -05:00
Kyle Affolder 25a6590324 Merge branch 'feature/dependabot-version-updates' of https://github.com/kaffolder7/kener into feature/dependabot-version-updates 2025-02-11 17:31:48 -05:00
Kyle Affolder fa251dc07f update: README generation workflow 2025-02-11 17:31:33 -05:00
Kyle 4de93e7e95 Merge branch 'main' into feature/dependabot-version-updates 2025-02-11 17:25:28 -05:00
Kyle Affolder a5e5f33dc8 Merge branch 'main' into feature/dependabot-version-updates
# Conflicts:
#	.github/workflows/publish-images.yml
#	Dockerfile
2025-02-11 17:17:45 -05:00
Kyle Affolder 4060094404 add(dependabot): to automate dependency updates
Integrating Dependabot into the workflow ensures automatic dependency updates, improving security, reducing technical debt, and keeping packages up to date with minimal manual effort. This helps prevent vulnerabilities and maintain code stability over time.

Dependabot will automatically monitor the project’s dependencies and open pull requests (PRs) to update them when new versions are released. Here’s how it works:

1. Scans for Outdated Dependencies – It checks project dependency files (e.g., package.json, Dockerfile, .env.build, etc.) for outdated versions.
2. Fetches Latest Versions – When a newer version of a dependency is available, Dependabot retrieves it and updates the dependency files accordingly.
3. Opens a Pull Request – It then creates a PR with the updated dependency, detailing the changes and linking to release notes, changelogs, or security advisories.
4. Runs CI/CD Tests – If we end up setting up continuous integration (CI) tests, the PR will trigger the tests to check for breaking changes.
5. Security Updates – Dependabot also detects vulnerable dependencies and creates PRs to update them to a secure version.
6. Auto-Merging (Optional) – We might consider this at a later point, but if configured, it can automatically merge PRs when updates pass all tests and meet the requirements.
2025-02-09 22:08:06 -05:00
156 changed files with 8448 additions and 2296 deletions
+1
View File
@@ -43,6 +43,7 @@ indent_size = 2
indent_style = space
indent_size = 4
trim_trailing_whitespace = false
print_width = 180
# Dockerfile
[Dockerfile*]
@@ -1,13 +0,0 @@
---
name: Create Incident Template
about: Create Incident Template
title: Title of Incident
labels: incident
assignees: ''
---
Your Incident Description goes here. Markdown Supported
[start_datetime:utcSeconds]
[end_datetime:utcSeconds]
+47
View File
@@ -0,0 +1,47 @@
version: 2
updates:
# Track base image versions via .env.build
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
file-patterns:
- ".env.build"
- "node:*" # Ensures Node.js images are correctly detected
# Monitor OS package versions in Dockerfile (Debian/Alpine)
- package-ecosystem: "gitsubmodule" # Alternative method to track OS packages in Dockerfile
directory: "/"
schedule:
interval: "weekly"
labels:
- "dependencies"
- "os-packages"
commit-message:
prefix: "os"
include: "scope"
# Monitor Node.js dependencies from package.json
# TODO: Uncomment below if we want to begin letting Dependabot monitor & open PRs for Node.js project dependencies
# - package-ecosystem: "npm"
# directory: "/"
# schedule:
# interval: "weekly"
# labels:
# - "dependencies"
# - "npm"
# commit-message:
# prefix: "npm"
# include: "scope"
# Monitor GitHub Actions dependencies
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
labels:
- "dependencies"
- "github-actions"
commit-message:
prefix: "actions"
include: "scope"
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20' # Adjust as needed
node-version: '23' # Adjust as needed
- name: Install dependencies
run: npm ci
@@ -153,4 +153,4 @@ jobs:
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
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
View File
@@ -9,6 +9,7 @@ config/server.yaml
/src/lib/.kener
/package
.env
.vscode
.env.*
!.env.example
vite.config.js.timestamp-*
+1 -1
View File
@@ -54,7 +54,7 @@
"semi": false,
"tabWidth": 4,
"trailingComma": "none",
"printWidth": 100
"printWidth": 180
}
},
{
+22 -22
View File
@@ -1,15 +1,15 @@
# syntax=docker/dockerfile:1
# Global build arguments
ARG ALPINE_VERSION=23.7.0-alpine3.21
ARG DEBIAN_VERSION=23.7.0-bookworm-slim
# Global build arguments (defined default values in case `.env.build` isn't loaded)
ARG ALPINE_VERSION_TAG=23.7.0-alpine3.21
ARG DEBIAN_VERSION_TAG=23.7.0-bookworm-slim
ARG VARIANT=debian
#==========================================================#
# STAGE 1: BUILD STAGE #
#==========================================================#
FROM node:${DEBIAN_VERSION} AS builder-debian
FROM node:${DEBIAN_VERSION_TAG} AS builder-debian
RUN apt-get update && apt-get install -y \
build-essential=12.9 \
python3=3.11.2-1+b1 \
@@ -18,11 +18,11 @@ RUN apt-get update && apt-get install -y \
make=4.3-4.1 \
node-gyp=9.3.0-2 \
g++=4:12.2.0-3 \
tzdata=2024b-0+deb12u1 \
tzdata \
iputils-ping=3:20221126-1+deb12u1 && \
rm -rf /var/lib/apt/lists/*
FROM node:${ALPINE_VERSION} AS builder-alpine
FROM node:${ALPINE_VERSION_TAG} AS builder-alpine
RUN apk add --no-cache --update \
build-base=0.5-r3 \
python3=3.12.9-r0 \
@@ -58,10 +58,9 @@ COPY . .
# TODO: Reevaluate permissions (possibly reduce?)...
# Remove docs directory and ensure required directories exist
RUN rm -rf src/routes/\(docs\) \
static/documentation \
static/fonts/lato/full && \
mkdir -p uploads database && \
# TODO: Consider changing below to `chmod -R u-rwX,g=rX,o= uploads database`
static/documentation \
static/fonts/lato/full && \
mkdir -p uploads database && \
chmod -R 750 uploads database
# Build the application and remove `devDependencies`
@@ -72,17 +71,17 @@ RUN npm run build && \
# STAGE 2: PRODUCTION/FINAL STAGE #
#==========================================================#
FROM node:${DEBIAN_VERSION} AS final-debian
FROM node:${DEBIAN_VERSION_TAG} 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 && \
tzdata \
# 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 && \
rm -rf /var/lib/apt/lists/*
FROM node:${ALPINE_VERSION} AS final-alpine
FROM node:${ALPINE_VERSION_TAG} AS final-alpine
RUN apk add --no-cache --update \
iputils=20240905-r0 \
sqlite=3.48.0-r0 \
@@ -119,6 +118,7 @@ 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
COPY --chown=node:node --from=builder /app/package.json ./package.json
# Ensure necessary directories are writable
VOLUME ["/uploads", "/database"]
@@ -126,19 +126,19 @@ 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
# 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'
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)
+9 -9
View File
@@ -79,7 +79,7 @@ npm run dev
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.2.0"><img src="https://img.shields.io/badge/Latest_Stable_Release-3.2.0-blue" alt="Kener latest stable version: 3.2.0" /></a>
<a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.2.11"><img src="https://img.shields.io/badge/Latest_Stable_Release-3.2.11-blue" alt="Kener latest stable version: 3.2.11" /></a>
#### Available Tags
@@ -93,38 +93,38 @@ Official Docker images for **Kener** are available on [Docker Hub](https://hub.d
</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.2.0)</td>
<td>Latest stable release (aka 3.2.11)</td>
</tr>
<tr>
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.2.0" target="_blank"><code>3.2.0</code></a></td>
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.2.11" target="_blank"><code>3.2.11</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.2" target="_blank"><code>3.2</code></a></td>
<td>Major-minor version tag pointing to the latest patch (3.2.0) release within that minor version (3.2.x)</td>
<td>Major-minor version tag pointing to the latest patch (3.2.11) release within that minor version (3.2.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.2.0) release within that major version (3.x.x)</td>
<td>Major version tag pointing to the latest stable (3.2.11) 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 &nbsp;<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.2.0)</td>
<td>Latest stable release (aka 3.2.11)</td>
</tr>
<tr>
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.2.0-alpine" target="_blank"><code>3.2.0-alpine</code></a></td>
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.2.11-alpine" target="_blank"><code>3.2.11-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.2-alpine" target="_blank"><code>3.2-alpine</code></a></td>
<td>Major-minor version tag pointing to the latest patch (3.2.0) release within that minor version (3.2.x)</td>
<td>Major-minor version tag pointing to the latest patch (3.2.11) release within that minor version (3.2.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.2.0) release within that major version (3.x.x)</td>
<td>Major version tag pointing to the latest stable (3.2.11) release within that major version (3.x.x)</td>
</tr>
</table>
+40
View File
@@ -7,6 +7,46 @@ description: Changelogs for Kener
Here are the changelogs for Kener. Changelogs are only published when there are new features or breaking changes.
## v3.2.5
<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 code editing for all JavaScript and JSON fields for trigger custom body and monitor `evals`.
- Syntax highlighting and error checking for monitor evaluation functions
- Added support for custom Discord and Slack body templates in triggers
- Improved variable templating for all notification types
- Added comprehensive user management with 3 roles: admin, editor, and member. Read more [here](/docs/rbac)
- Self-service profile management for all users
- User activation/deactivation controls for admins
- Added dedicated Badges management page in the admin dashboard
- Site-wide status badges to show overall service health
- Version information automatically pulled from package.json
### Improvements
- Improved build time by migrating lucid-svelte to individual components
### Github Issues Resolved
- Feature: Enhancement of Webhook Custom Body Functionality [#238](https://github.com/rajnandan1/kener/issues/238)
- Feature: Add LDAP for authentication [#210](https://github.com/rajnandan1/kener/issues/210)
- the event created by tigger can't update affects. [#309](https://github.com/rajnandan1/kener/issues/309)
- Multiple account or RBAC [#334](https://github.com/rajnandan1/kener/issues/334)
- The event that has been edited isn't clickable after you save it [#350](https://github.com/rajnandan1/kener/issues/350)
- /app/package.json ERROR on fresh install [#346](https://github.com/rajnandan1/kener/issues/346)
- Site stops when monitor processing throws an error [#345](https://github.com/rajnandan1/kener/issues/345)
- Support for Self Signed Certificates [#351](https://github.com/rajnandan1/kener/issues/351)
- Wrong month is showing up after clicking on the 'Browse Events' link [#349](https://github.com/rajnandan1/kener/issues/349)
### Migration Notes
This update is fully backward compatible with existing installations. User accounts created before this update will retain admin privileges by default.
## v3.2.1
<picture>
+44 -1
View File
@@ -33,10 +33,53 @@ To add inline css go to Manage kener -> Theme -> Custom CSS and add your CSS the
```css
.my-class {
color: red;
color: red;
}
```
<div class="note danger">
Do not include &#x3C;style&#x3E; tags.
</div>
## Grid Layout
To change from a column layout to a grid layout in your Kener instance, you can use the following CSS:
```css
@media (min-width: 1330px) {
.section-monitors,
.section-categories,
.section-hero,
.section-back,
.section-events,
.section-legend,
.section-categories,
.section-browser-events {
width: 1330px !important;
max-width: 1330px !important;
}
.section-monitors .monitor-root {
background-color: transparent;
border: none !important;
box-shadow: none;
}
.section-monitors .monitor-root .monitors-card {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
.section-monitors .monitor-root .monitors-card .monitor {
grid-column: span 1;
border-radius: 0.375rem;
background-color: hsl(var(--card) / var(--tw-bg-opacity, 1));
border-width: 1px;
}
.section-monitors .monitor-root .monitors-card .monitor:last-child {
border-bottom: inherit;
border-bottom-width: 1px !important;
}
}
```
+9 -2
View File
@@ -25,6 +25,13 @@ Set this to the origin of your website(protocl + hostname + port if there). This
export ORIGIN=http://localhost:3000
```
If your host is at let us say `https://example.com`, then set the ORIGIN to `https://example.com`. This is required for CORS.
If you are using a reverse proxy like nginx, make sure to set the `ORIGIN` to the domain name of your website.
```bash
export ORIGIN=https://example.com
```
## PORT
Defaults to 3000 if not specified
@@ -37,8 +44,8 @@ export PORT=4242
By default kener runs on `/` but you can change it to `/status` or any other path.
- 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: 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
```bash
export KENER_BASE_PATH=/status
+134
View File
@@ -0,0 +1,134 @@
---
title: Kener Role Based Access Control (RBAC)
description: Role-Based Access Control (RBAC) in Kener
---
# Role-Based Access Control (RBAC) in Kener
Kener includes a comprehensive role-based access control system that allows you to manage user permissions and access to various features. This document explains the available roles, their permissions, and how to manage users effectively.
## Available Roles
Kener offers three different roles with varying levels of permissions:
| Role | Description |
| ------ | ------------------------------------------------------------------------------ |
| Admin | Full access to all features including user management and system configuration |
| Editor | Can create and edit monitors, triggers, incidents, and other operational data |
| Member | Read-only access with limited interaction capabilities |
## Role Permissions
### Admin
Admins have unrestricted access to the entire system:
- **User Management**:
- Create, update, and deactivate users
- Change user roles
- Reset passwords for other users
- Send verification emails
- **System Configuration**:
- Configure all site settings
- Manage API keys
- Set up triggers and integrations
- **Operational Access**:
- Full access to create and manage monitors
- Create and update incidents
- Run tests on monitors and triggers
- Configure all notification channels
### Editor
Editors can manage most operational aspects but cannot administer users:
- **Content Management**:
- Create and edit monitors
- Configure monitor settings
- Create and manage triggers
- Create and update incidents
- Add incident updates and comments
- **Limited Access**:
- Cannot manage users
- Cannot change role assignments
- Cannot access certain system-level configurations
### Member
Members have read-only access with minimal interaction capabilities:
- **View Access**:
- View all monitors and their status
- View incidents and their history
- See system configuration (but cannot modify)
- **Limited Interactions**:
- Can test existing triggers but cannot create or edit them
- Cannot create or update incidents
- Cannot modify any system configuration
## Managing Users
### Adding New Users
Only Admins and Editors can add new users to the system:
1. Navigate to the **Users** page in the management dashboard
2. Click the **Add New User** button
3. Fill in the required information:
- Name
- Email
- Password
- Role (Member or Editor)
4. Click **Add User** to create the account
When a new user is added, a verification email can be sent to confirm their email address if email sending is configured.
### User Settings
Admins can manage user accounts through the user settings page:
- **Email Verification**: Send verification emails to users
- **Password Reset**: Reset a user's password
- **Role Management**: Change a user's role between Member and Editor
- **Account Status**: Activate or deactivate user accounts
### Best Practices
1. **Follow the Principle of Least Privilege**:
- Assign the minimum necessary permissions for users to perform their job
- Start users with the Member role and elevate as needed
2. **Regular Access Reviews**:
- Periodically review user access and roles
- Remove access for users who no longer need it
3. **Admin Accounts**:
- Limit the number of Admin accounts
- Use strong passwords for Admin accounts
- Consider using email verification for all users
## Role Limitations
- The Admin role can only be assigned during initial setup
- Members cannot create or modify content
- Users cannot modify their own role (only an Admin can change roles)
## Email Verification
When email sending is configured, users can verify their email addresses:
1. Admins can send verification emails from the user management interface
2. Users receive an email with a verification link
3. After clicking the link, the user's email is marked as verified
Email verification improves security and ensures that users have provided valid email addresses for notifications.
+11 -1
View File
@@ -21,7 +21,17 @@ Add API key of your Amplitude account
### Mixpanel
Add token of your Mixpanel account.
Add project token of your Mixpanel account.
### Plausible
- Add domain of your Plausible account. Example: `example.com`
- Add API key of your Plausible account. Default is `https://plausible.io/api/event`
- Add Script Source of your Plausible account. Default is `https://plausible.io/js/script.pageview-props.tagged-events.js`
### Microsoft Clarity
Add Project ID of your Microsoft Clarity account.
## Search Engine Optimization
+5
View File
@@ -28,6 +28,11 @@
"title": "Databases",
"link": "/docs/database",
"file": "/database.md"
},
{
"title": "Access Control",
"link": "/docs/rbac",
"file": "/rbac.md"
}
]
},
+221 -45
View File
@@ -3,7 +3,7 @@ title: Triggers | Kener
description: Learn how to set up and work with triggers in kener.
---
# Triggers
## Triggers
Triggers are used to trigger actions based on the status of your monitors. You can use triggers to send notifications, or call webhooks when a monitor goes down or up.
@@ -27,10 +27,36 @@ The description is used to define the description of the webhook. It is optional
Kener supports the following triggers:
- [Webhook](#webhook)
- [Discord](#discord)
- [Slack](#slack)
- [Email](#email)
- [Webhook](#webhook)
- [Discord](#discord)
- [Slack](#slack)
- [Email](#email)
### Trigger Variables
You can use the following variables in the triggers using mustache syntax.
| Key | Description | Mustache Variable |
| ------------- | ------------------------------------------------------------ | ----------------- |
| site_name | [string] Name of your site | {{site_name}} |
| logo_url | [string] Logo of your site | {{logo_url}} |
| site_url | [string] Url of your site | {{site_url}} |
| alert_name | [string] Name of the alert | {{alert_name}} |
| status | [string] Status of the alert. Can be `TRIGGERED`, `RESOLVED` | {{status}} |
| is_resolved | [bool] True if alert is `RESOLVED` | {{is_resolved}} |
| is_triggered | [bool] True if alert is `TRIGGERED` | {{is_triggered}} |
| description | [string] Description of the alert. | {{description}} |
| action_text | [string] CTA for next action on the alert | {{action_text}} |
| action_url | [string] URL for next action on the alert | {{action_url}} |
| metric | [string] Name of the monitor | {{metric}} |
| severity | [string] Severity of the alert. Can be `critical`, `warn` | {{severity}} |
| id | [string] Id of the alert | {{id}} |
| current_value | [number] Current count of failures | {{current_value}} |
| threshold | [number] Threshold Failue | {{threshold}} |
| source | [string] Source of the alert | {{source}} |
| timestamp | [string] Timestamp **ISO 8601** formatted | {{timestamp}} |
---
## Webhook
@@ -93,24 +119,65 @@ Body of the webhook will be sent as below:
}
```
| 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.
Using [mustache variables](#triggers-trigger-variables) you can customize the body of the webhook. If you are not using a json body then please make sure you are using the right content-type by setting custom headers.
### Examples
#### 1. 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>.
#### 2. Conditional Webhook
You can use `is_triggered` and `is_resolved` to create conditional webhooks.
```json
{
"title": "Monitor Alert for {{metric}} has been {{#is_triggered}}🔴 Triggered{{/is_triggered}}{{#is_resolved}}🟢 Resolved{{/is_resolved}}. Current value is {{current_value}} and threshold set is {{threshold}}"
}
```
The output will be
```json
{
"title": "Monitor Alert for Mockoon has been 🔴 Triggered. Current value is 1 and threshold set is 1"
}
```
```json
{
"title": "Monitor Alert for Mockoon has been 🟢 Resolved. Current value is 0 and threshold set is 1"
}
```
#### 3. Combing mustache with env variables
Combine the above example with `NODE_ENV` environment variable.
```json
{
"title": "Monitor Alert for {{metric}} in environment $NODE_ENV has been {{#is_triggered}}🔴 Triggered{{/is_triggered}}{{#is_resolved}}🟢 Resolved{{/is_resolved}}. Current value is {{current_value}} and threshold set is {{threshold}}."
}
```
---
## Discord
@@ -149,6 +216,60 @@ The discord message when alert is `RESOLVED` will look like this
![Discord](/documentation/discord_resolved.png)
### Modify Discord Message
You can modify the discord message by using [mustache variables](#triggers-trigger-variables).
```json
{
"username": "{{site_name}}",
"avatar_url": "{{{logo_url}}}",
"content": "## {{alert_name}}\n{{#is_triggered}}🔴 Triggered{{/is_triggered}}{{#is_resolved}}🟢 Resolved{{/is_resolved}}\n{{description}}\nClick [MY CTA]({{{action_url}}}) for more.",
"embeds": [
{
"title": "❌{{alert_name}}❌",
"description": "{{description}}",
"url": "{{{action_url}}}",
"color": "{{#is_triggered}}13250616{{/is_triggered}}{{#is_resolved}}5156244{{/is_resolved}}",
"fields": [
{
"name": "Monitor",
"value": "{{metric}}",
"inline": false
},
{
"name": "Severity",
"value": "{{severity}}",
"inline": false
},
{
"name": "Alert ID",
"value": "{{id}}",
"inline": false
},
{
"name": "Current Value",
"value": "{{current_value}}",
"inline": true
},
{
"name": "Threshold",
"value": "{{threshold}}",
"inline": true
}
],
"footer": {
"text": "{{source}}",
"icon_url": "{{{logo_url}}}"
},
"timestamp": "{{timestamp}}"
}
]
}
```
---
## Slack
Slack triggers are used to send a message to a slack channel when a monitor goes down or up.
@@ -187,6 +308,81 @@ The slack message when alert is `RESOLVED` will look like this
![Slack](/documentation/slack_resolved.png)
### Modify Slack Message
You can modify the slack message by using [mustache variables](#triggers-trigger-variables).
```json
{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "Hello Alert {{alert_name}}",
"emoji": true
}
},
{
"type": "header",
"text": {
"type": "plain_text",
"text": "{{#is_triggered}}🔴 Triggered{{/is_triggered}}{{#is_resolved}}🟢 Resolved{{/is_resolved}}",
"emoji": true
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "{{description}}\n*Source:* {{source}}\n*Severity:* {{severity}}\n*Status:* {{status}}"
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Metric:*\n{{metric}}"
},
{
"type": "mrkdwn",
"text": "*Current Value:*\n{{current_value}}"
},
{
"type": "mrkdwn",
"text": "*Threshold:*\n{{threshold}}"
},
{
"type": "mrkdwn",
"text": "*Environment:*\n$_NODE_ENV"
},
{
"type": "mrkdwn",
"text": "*Timestamp:*\n<!date^{{timestamp_unix}}^{date} at {time}|{{timestamp}}>"
}
]
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {
"type": "plain_text",
"text": "{{action_text}}"
},
"url": "{{{action_url}}}",
"style": "primary"
}
]
}
]
}
```
---
## Email
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.
@@ -205,10 +401,10 @@ To send emails using Resend you just need to set `RESEND_API_KEY` in the environ
To send emails using SMTP, please enter
- Host: SMTP server host
- Port: SMTP server port
- User: SMTP server username
- Password: SMTP server password
- Host: SMTP server host
- Port: SMTP server port
- User: SMTP server username
- Password: SMTP server password
<div class="note danger">
@@ -273,23 +469,3 @@ Click on the ⚙️ to edit the 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>.
+16 -17
View File
@@ -1,20 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
body {
background-color: #111;
}
</style>
</head>
<body>
Hello world
<script
async
src="http://localhost:3000/embed/monitor-earth/js?theme=dark&bgc=111&locale=hi&monitor=http://localhost:3000/embed/monitor-earth"
></script>
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
body {
background-color: #111;
}
</style>
</head>
<body>
<script
async
src="http://localhost:3000/embed/monitor-bitbuckettcp/js?bgc=212226&locale=en&theme=dark&monitor=http://localhost:3000/embed/monitor-bitbuckettcp"
></script>
</body>
</html>
@@ -0,0 +1,38 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export function up(knex) {
return knex.schema.createTable("invitations", (table) => {
// Primary key
table.increments("id").primary();
// Core invitation fields
table.string("invitation_token").unique().notNullable();
table.string("invitation_type").notNullable();
table.integer("invited_user_id").nullable();
table.integer("invited_by_user_id").notNullable();
// Additional data fields
table.text("invitation_meta").nullable(); // For storing JSON or other metadata
table.timestamp("invitation_expiry").notNullable();
table.string("invitation_status").notNullable().defaultTo("PENDING");
// Timestamps
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Indexes
table.index("invitation_status");
table.index("invitation_expiry");
table.index(["invited_by_user_id", "invitation_status"]);
});
}
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export function down(knex) {
return knex.schema.dropTableIfExists("invitations");
}
+347 -46
View File
@@ -1,17 +1,24 @@
{
"name": "kener",
"version": "3.1.8",
"version": "3.2.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kener",
"version": "3.1.8",
"version": "3.2.11",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.10",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.3",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.2",
"@formkit/auto-animate": "^0.8.2",
"@number-flow/svelte": "^0.2.1",
"@scalar/express-api-reference": "^0.4.167",
"@uiw/codemirror-theme-github": "^4.23.10",
"analytics": "^0.8.14",
"axios": "^1.6.2",
"badge-maker": "^3.3.1",
@@ -31,11 +38,12 @@
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"knex": "^3.1.0",
"lucide-svelte": "^0.292.0",
"lucide-svelte": "^0.483.0",
"marked": "^11.1.1",
"mode-watcher": "^0.4.1",
"moment": "^2.29.4",
"moment-timezone": "^0.5.43",
"mustache": "^4.2.0",
"mysql2": "^3.12.0",
"node-cache": "^5.1.2",
"nodemailer": "^6.10.0",
@@ -46,9 +54,11 @@
"queue": "^7.0.0",
"randomstring": "^1.3.0",
"resend": "^4.0.1",
"svelte-codemirror-editor": "^1.4.1",
"svelte-legos": "^0.2.5",
"tailwind-merge": "^2.0.0",
"tailwind-variants": "^0.1.18"
"tailwind-variants": "^0.1.18",
"vite-plugin-package-version": "^1.1.0"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^2.0.0",
@@ -60,7 +70,6 @@
"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",
@@ -176,10 +185,9 @@
"license": "MIT"
},
"node_modules/@babel/runtime": {
"version": "7.26.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz",
"integrity": "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==",
"dev": true,
"version": "7.26.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
"integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
@@ -188,6 +196,158 @@
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.18.6",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz",
"integrity": "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.0.tgz",
"integrity": "sha512-q8VPEFaEP4ikSlt6ZxjB3zW72+7osfAYW9i8Zu943uqbKuz6utc1+F170hyLUCUltXORjQXRyYQNfkckzA/bPQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz",
"integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.0"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.3.tgz",
"integrity": "sha512-8PR3vIWg7pSu7ur8A07pGiYHgy3hHj+mRYRCSG8q+mPIrl0F02rgpGv+DsQTHRTc30rydOsf5PZ7yjKFg2Ackw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.1.tgz",
"integrity": "sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.3.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.3.2.tgz",
"integrity": "sha512-c/5MYinGbFxYl4itE9q/rgN/sMTjOr8XL5OWnC+EaRMLfCbVUmmubTJfdgpfcSS2SCaT7b+Q+xi3l6CgoE+BsA==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.0.tgz",
"integrity": "sha512-A7+f++LodNNc1wGgoRDTt78cOwWm9KVezApgjOMp1W4hM0898nsqBXwF+sbePE7ZRcjN7Sa1Z5m2oN27XkmEjQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.1.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.8.4",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.4.tgz",
"integrity": "sha512-u4q7PnZlJUojeRe8FJa/njJcMctISGgPQ4PnWsd9268R4ZTtU+tfFYmwkBvgcrK2+QQ8tYFVALVb5fVJykKc5A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.5.10",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.10.tgz",
"integrity": "sha512-RMdPdmsrUf53pb2VwflKGHEe1XVM07hI7vV2ntgw1dmqhimpatSJKva4VA9h4TLUDOD4EIF02201oZurpnEFsg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.36.4",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.36.4.tgz",
"integrity": "sha512-ZQ0V5ovw/miKEXTvjgzRyjnrk9TwriUB1k4R5p7uNnHR9Hus+D1SXHGdJshijEzPFjU25xea/7nhIeSqYFKdbA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.5.0",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
@@ -195,7 +355,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -212,7 +371,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -229,7 +387,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -246,7 +403,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -263,7 +419,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -280,7 +435,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -297,7 +451,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -314,7 +467,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -331,7 +483,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -348,7 +499,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -365,7 +515,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -382,7 +531,6 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -399,7 +547,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -416,7 +563,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -433,7 +579,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -450,7 +595,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -467,7 +611,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -484,7 +627,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -501,7 +643,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -518,7 +659,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -535,7 +675,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -552,7 +691,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -756,6 +894,84 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz",
"integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.11.tgz",
"integrity": "sha512-FuAnusbLBl1SEAtfN8NdShxYJiESKw9LAFysfea1T96jD3ydBn12oYjaSG1a04BQRIUd93/0D8e5CV1cUMkmQg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
"integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz",
"integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.4.21",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.21.tgz",
"integrity": "sha512-lL+1fcuxWYPURMM/oFZLEDm0XuLN128QPV+VuGtKpeaOGdcl9F2LYC3nh1S9LkPqx9M0mndZFdXCipNAZpzIkQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz",
"integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.4.2.tgz",
"integrity": "sha512-iYewCigG/517D0xJPQd7RGaCjZAFwROiH8T9h7OTtz0bRVtkxzFhGBFJ9JGKgBBs4uuo1cvxzyQ5iKhDLMcLUQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
@@ -776,6 +992,12 @@
"node-pre-gyp": "bin/node-pre-gyp"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@melt-ui/svelte": {
"version": "0.61.2",
"resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.61.2.tgz",
@@ -1204,6 +1426,37 @@
"dev": true,
"license": "MIT"
},
"node_modules/@uiw/codemirror-theme-github": {
"version": "4.23.10",
"resolved": "https://registry.npmjs.org/@uiw/codemirror-theme-github/-/codemirror-theme-github-4.23.10.tgz",
"integrity": "sha512-jTg2sHAcU1d+8x0O+EBDI71rtJ8PWKIW8gzy+SW4wShQTAdsqGHk5y1ynt3KIeoaUkqngLqAK4SkhPaUKlqZqg==",
"license": "MIT",
"dependencies": {
"@uiw/codemirror-themes": "4.23.10"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
}
},
"node_modules/@uiw/codemirror-themes": {
"version": "4.23.10",
"resolved": "https://registry.npmjs.org/@uiw/codemirror-themes/-/codemirror-themes-4.23.10.tgz",
"integrity": "sha512-dU0UgEEgEXCAYpxuVDQ6fovE82XsqgHZckTJOH6Bs8xCi3Z7dwBKO4pXuiA8qGDwTOXOMjSzfi+pRViDm7OfWw==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@codemirror/language": ">=6.0.0",
"@codemirror/state": ">=6.0.0",
"@codemirror/view": ">=6.0.0"
}
},
"node_modules/@unhead/schema": {
"version": "1.11.18",
"resolved": "https://registry.npmjs.org/@unhead/schema/-/schema-1.11.18.tgz",
@@ -1525,9 +1778,9 @@
}
},
"node_modules/axios": {
"version": "1.7.9",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
"integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
@@ -2095,6 +2348,22 @@
"@types/estree": "^1.0.0"
}
},
"node_modules/codemirror": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz",
"integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2267,6 +2536,12 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/croner": {
"version": "7.0.8",
"resolved": "https://registry.npmjs.org/croner/-/croner-7.0.8.tgz",
@@ -2994,7 +3269,6 @@
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
"integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -4779,12 +5053,12 @@
}
},
"node_modules/lucide-svelte": {
"version": "0.292.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.292.0.tgz",
"integrity": "sha512-bnTpg9pbm6pQDc+YiLK2yxtRFk2Cc+hbzwjAPaV85k56x10CJ9LsXjon6wRrlNTSdxJR7GOsRjz0A5ZNu3Z7dg==",
"version": "0.483.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.483.0.tgz",
"integrity": "sha512-MyMgEVLlFfPbyodGpkB+KCpyPkpjI7EKiFw1crA92B1ZXRK5hq5vTsGWAm9Nt3GAKHunoNc5MVsq3EOCz0DZSQ==",
"license": "ISC",
"peerDependencies": {
"svelte": ">=3 <5"
"svelte": "^3 || ^4 || ^5.0.0-next.42"
}
},
"node_modules/magic-string": {
@@ -5106,7 +5380,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
"dev": true,
"license": "MIT",
"bin": {
"mustache": "bin/mustache"
@@ -6337,9 +6610,9 @@
"license": "MIT"
},
"node_modules/prismjs": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz",
"integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==",
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
@@ -6624,7 +6897,6 @@
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"dev": true,
"license": "MIT"
},
"node_modules/regexp.prototype.flags": {
@@ -6771,7 +7043,6 @@
"version": "3.29.5",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz",
"integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==",
"dev": true,
"license": "MIT",
"bin": {
"rollup": "dist/bin/rollup"
@@ -7584,6 +7855,12 @@
"node": ">=0.10.0"
}
},
"node_modules/style-mod": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz",
"integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==",
"license": "MIT"
},
"node_modules/sucrase": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
@@ -7794,6 +8071,16 @@
"svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0"
}
},
"node_modules/svelte-codemirror-editor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/svelte-codemirror-editor/-/svelte-codemirror-editor-1.4.1.tgz",
"integrity": "sha512-Pv350iro0Y/AZTT/y2OLaonheQqAwl50Hdfipa2Jv1Z04TSP5kPUyxQnRjqxeRW7DXOX9s5Nd11tHdBl9iYSzw==",
"license": "MIT",
"peerDependencies": {
"codemirror": "^6.0.0",
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0"
}
},
"node_modules/svelte-dnd-action": {
"version": "0.9.57",
"resolved": "https://registry.npmjs.org/svelte-dnd-action/-/svelte-dnd-action-0.9.57.tgz",
@@ -8419,7 +8706,6 @@
"version": "4.5.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.9.tgz",
"integrity": "sha512-qK9W4xjgD3gXbC0NmdNFFnVFLMWSNiR3swj957yutwzzN16xF/E7nmtAyp1rT9hviDroQANjE4HK3H4WqWdFtw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.18.10",
@@ -8471,6 +8757,15 @@
}
}
},
"node_modules/vite-plugin-package-version": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/vite-plugin-package-version/-/vite-plugin-package-version-1.1.0.tgz",
"integrity": "sha512-TPoFZXNanzcaKCIrC3e2L/TVRkkRLB6l4RPN/S7KbG7rWfyLcCEGsnXvxn6qR7fyZwXalnnSN/I9d6pSFjHpEA==",
"license": "MIT",
"peerDependencies": {
"vite": ">=2.0.0-beta.69"
}
},
"node_modules/vitefu": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz",
@@ -8486,6 +8781,12 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+13 -4
View File
@@ -1,6 +1,6 @@
{
"name": "kener",
"version": "3.2.2",
"version": "3.2.11",
"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.",
@@ -52,7 +52,6 @@
"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",
@@ -68,9 +67,16 @@
},
"type": "module",
"dependencies": {
"@babel/runtime": "^7.26.10",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.3",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.2",
"@formkit/auto-animate": "^0.8.2",
"@number-flow/svelte": "^0.2.1",
"@scalar/express-api-reference": "^0.4.167",
"@uiw/codemirror-theme-github": "^4.23.10",
"analytics": "^0.8.14",
"axios": "^1.6.2",
"badge-maker": "^3.3.1",
@@ -90,11 +96,12 @@
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"knex": "^3.1.0",
"lucide-svelte": "^0.292.0",
"lucide-svelte": "^0.483.0",
"marked": "^11.1.1",
"mode-watcher": "^0.4.1",
"moment": "^2.29.4",
"moment-timezone": "^0.5.43",
"mustache": "^4.2.0",
"mysql2": "^3.12.0",
"node-cache": "^5.1.2",
"nodemailer": "^6.10.0",
@@ -105,9 +112,11 @@
"queue": "^7.0.0",
"randomstring": "^1.3.0",
"resend": "^4.0.1",
"svelte-codemirror-editor": "^1.4.1",
"svelte-legos": "^0.2.5",
"tailwind-merge": "^2.0.0",
"tailwind-variants": "^0.1.18"
"tailwind-variants": "^0.1.18",
"vite-plugin-package-version": "^1.1.0"
},
"engines": {
"node": ">=20.0.0"
-4
View File
@@ -1,7 +1,3 @@
code:not([class^="language-"]) {
@apply rounded bg-gray-100 px-1.5 py-0.5 font-mono text-xs dark:bg-gray-800;
}
.sidebar-item.active,
.sidebar-item:hover {
color: #ed702d;
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
const response = await resolve(event);
response.headers.delete("Link");
return response;
}
+69 -98
View File
@@ -1,108 +1,10 @@
/*one is the class for dotted background*/
.dots-pattern {
position: absolute;
top: 0px;
left: 0;
width: 100%;
z-index: 0;
background-repeat: no-repeat;
background-size: 100%;
height: 100svh;
background: linear-gradient(177deg, rgba(255, 137, 131, 0.5) 0%, rgba(35, 136, 224, 0.05) 60%);
clip-path: polygon(0 0, 100% 0, 100% 54%, 0% 100%);
}
.dots-pattern::after {
content: "";
position: absolute;
background-image: radial-gradient(rgba(0, 0, 0, 0) 1.5px, var(--background-kener) 1px);
background-size: 14px 14px;
width: 100%;
height: 100vh;
top: 0;
transform: blur(3px);
left: 0;
}
.squares-pattern {
position: absolute;
top: 0;
left: 0;
width: 100%;
z-index: 0;
background-repeat: no-repeat;
background-size: 100%;
height: 100svh;
/* clip-path: polygon(0 0, 100% 0, 100% 54%, 0% 100%); */
}
.squares-pattern::after {
content: "";
position: absolute;
background-image:
linear-gradient(#444cf7 1px, transparent 1px),
linear-gradient(to right, #444cf7 1px, var(--background-kener) 1px);
-webkit-mask-image: linear-gradient(
180deg,
rgba(0, 0, 0, 1) 0%,
rgba(0, 0, 0, 0.8) 20%,
rgba(0, 0, 0, 0.6) 40%,
rgba(0, 0, 0, 0.4) 60%,
rgba(0, 0, 0, 0.2) 80%,
rgba(0, 0, 0, 0) 100%
);
mask-image: linear-gradient(
180deg,
rgba(0, 0, 0, 0.2) 0%,
rgba(0, 0, 0, 0.1) 20%,
rgba(0, 0, 0, 0.05) 40%,
rgba(0, 0, 0, 0.025) 60%,
rgba(0, 0, 0, 0.0125) 80%,
rgba(0, 0, 0, 0) 100%
);
background-size: 32px 32px;
width: 100%;
height: 100vh;
top: 0;
left: 0;
}
.dark .squares-pattern::after {
-webkit-mask-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.5) 0%,
rgba(0, 0, 0, 0.25) 20%,
rgba(0, 0, 0, 0.125) 40%,
rgba(0, 0, 0, 0.0625) 60%,
rgba(0, 0, 0, 0.03125) 80%,
rgba(0, 0, 0, 0) 100%
);
mask-image: linear-gradient(
180deg,
rgba(0, 0, 0, 0.5) 0%,
rgba(0, 0, 0, 0.25) 20%,
rgba(0, 0, 0, 0.125) 40%,
rgba(0, 0, 0, 0.0625) 60%,
rgba(0, 0, 0, 0.03125) 80%,
rgba(0, 0, 0, 0) 100%
);
background-image:
linear-gradient(#616fbf 1px, transparent 1px),
linear-gradient(to right, #616fbf 1px, var(--background-kener) 1px);
}
/*Needed to overlay content on top of dotted bg*/
section {
position: relative;
z-index: 1;
}
/*Needed overlay content on top of dotted bg*/
.blurry-bg {
/* background-color: var(--background-kener-rgba);
box-shadow: 0 0 64px 64px var(--background-kener-rgba); */
}
:root {
--up-color: #4ead94;
@@ -905,3 +807,72 @@ textarea::placeholder {
content: ".";
}
}
.dark {
--cp-bg-color: hsl(var(--background) / var(--tw-bg-opacity, 1));
--cp-border-color: hsl(var(--border) / var(--tw-border-opacity));
--cp-text-color: hsl(var(--card-foreground) / var(--tw-text-opacity, 1));
}
.color-picker .container {
padding-left: 0px;
padding-right: 0px;
}
.color-picker .color {
border: 1px solid hsl(var(--border) / var(--tw-border-opacity));
}
.color-picker button {
display: none;
}
.color-picker [aria-label="color picker"] input {
color: hsl(var(--foreground) / var(--tw-border-opacity)) !important;
background-color: hsl(var(--input) / var(--tw-text-opacity, 1)) !important;
}
.dark .color-picker {
--cp-bg-color: hsl(var(--background) / var(--tw-bg-opacity, 1));
--cp-border-color: hsl(var(--border) / var(--tw-border-opacity));
--cp-text-color: hsl(var(--card-foreground) / var(--tw-text-opacity, 1));
--cp-input-color: hsl(var(--input) / var(--tw-text-opacity, 1));
--cp-button-hover-color: hsl(var(--input) / var(--tw-text-opacity, 1));
}
.like-input {
background-color: rgba(0, 0, 0, 0.02) !important;
}
.dark .like-input {
background-color: rgba(0, 0, 0, 0.1) !important;
}
.ͼo .cm-gutters {
border-top-left-radius: var(--radius);
border-bottom-left-radius: var(--radius);
}
.no-gutter .ͼo .cm-gutters {
display: none;
}
.kener-md {
@apply text-sm;
}
p code:not([class^="language-"]) {
@apply rounded-sm bg-yellow-100 px-1 py-0.5 font-mono text-xs dark:bg-neutral-700 dark:text-neutral-100;
}
.kener-theme-mono .section-hero .h1,
.kener-theme-mono-hero-h1 {
@apply text-foreground;
}
.kener-theme-default .section-hero .h1,
.kener-theme-default-hero-h1 {
@apply bg-gradient-to-r from-green-300 via-blue-500 to-purple-600 bg-clip-text text-transparent;
}
.kener-theme-sunset .section-hero .h1,
.kener-theme-sunset-hero-h1 {
@apply bg-gradient-to-r from-yellow-500 via-red-500 to-pink-500 bg-clip-text text-transparent;
}
.kener-theme-forest .section-hero .h1,
.kener-theme-forest-hero-h1 {
@apply bg-gradient-to-l from-blue-500 via-teal-500 to-green-500 bg-clip-text text-transparent;
}
.kener-theme-ocean .section-hero .h1,
.kener-theme-ocean-hero-h1 {
@apply bg-gradient-to-l from-cyan-500 via-blue-500 to-indigo-500 bg-clip-text text-transparent;
}
+100
View File
@@ -0,0 +1,100 @@
<script>
import { onMount, onDestroy, beforeUpdate } from "svelte";
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
export let monitor;
export let theme = "light";
export let bgc = "transparent";
export let locale = "en";
let iframe, listeners;
let containerId = `embed-container-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
let isMounted = false;
let prevMonitor, prevTheme, prevBgc, prevLocale;
onMount(() => {
if (!monitor) return;
isMounted = true;
prevMonitor = monitor;
prevTheme = theme;
prevBgc = bgc;
prevLocale = locale;
const container = document.getElementById(containerId);
if (!container) return;
const uid = "KENER_" + ~~(new Date().getTime() / 86400000);
const uriEmbedded = `${monitor}?theme=${theme}&bgc=${bgc}&locale=${locale}`;
iframe = document.createElement("iframe");
iframe.src = uriEmbedded;
iframe.id = uid;
iframe.width = "0%";
iframe.height = "0";
iframe.frameBorder = "0";
iframe.allowTransparency = true;
iframe.sandbox =
"allow-modals allow-forms allow-same-origin allow-scripts allow-popups allow-top-navigation-by-user-activation allow-downloads";
iframe.allow = "midi; geolocation; microphone; camera; display-capture; encrypted-media;";
container.appendChild(iframe);
const setHeight = (data) => {
if (data.height !== undefined) {
iframe.height = data.height;
}
};
const setWidth = (data) => {
if (data.width !== undefined) {
iframe.width = data.width;
}
};
listeners = (event) => {
if (event.data && event.data.height !== undefined) {
setHeight(event.data);
}
if (event.data && event.data.width !== undefined) {
setWidth(event.data);
}
};
window.addEventListener("message", listeners, false);
});
beforeUpdate(() => {
if (
isMounted &&
iframe &&
(monitor !== prevMonitor || theme !== prevTheme || bgc !== prevBgc || locale !== prevLocale)
) {
iframe.src = `${monitor}?theme=${theme}&bgc=${bgc}&locale=${locale}`;
// Update previous values
prevMonitor = monitor;
prevTheme = theme;
prevBgc = bgc;
prevLocale = locale;
dispatch("update", {
monitor: prevMonitor,
theme: prevTheme,
bgc: prevBgc,
locale: prevLocale
});
}
});
onDestroy(() => {
if (iframe) {
window.removeEventListener("message", listeners);
iframe.remove();
}
});
</script>
<!-- Dynamic container with unique ID -->
<div id={containerId}></div>
+194
View File
@@ -0,0 +1,194 @@
export const DiscordJSONTemplate = JSON.stringify(
{
username: "{{site_name}}",
avatar_url: "{{{logo_url}}}",
content:
"## {{alert_name}}\n{{#is_triggered}}🔴 Triggered{{/is_triggered}}{{#is_resolved}}🟢 Resolved{{/is_resolved}}\n{{description}}\nClick [{{action_text}}]({{{action_url}}}) for more.",
embeds: [
{
title: "{{alert_name}}",
description: "{{description}}",
url: "{{{action_url}}}",
color: "{{#is_triggered}}13250616{{/is_triggered}}{{#is_resolved}}5156244{{/is_resolved}}",
fields: [
{
name: "Monitor",
value: "{{metric}}",
inline: false,
},
{
name: "Severity",
value: "{{severity}}",
inline: false,
},
{
name: "Alert ID",
value: "{{id}}",
inline: false,
},
{
name: "Current Value",
value: "{{current_value}}",
inline: true,
},
{
name: "Threshold",
value: "{{threshold}}",
inline: true,
},
],
footer: {
text: "{{source}}",
icon_url: "{{{logo_url}}}",
},
timestamp: "{{timestamp}}",
},
],
},
null,
2,
);
export const WebhookJSONTemplate = JSON.stringify(
{
id: "{{id}}",
alert_name: "{{alert_name}}",
severity: "{{severity}}",
status: "{{status}}",
source: "{{source}}",
timestamp: "{{timestamp}}",
description: "{{description}}",
details: {
metric: "{{metric}}",
current_value: "{{current_value}}",
threshold: "{{threshold}}",
},
actions: [
{
text: "{{action_text}}",
url: "{{{action_url}}}",
},
],
},
null,
2,
);
export const SlackJSONTemplate = JSON.stringify(
{
blocks: [
{
type: "header",
text: {
type: "plain_text",
text: "{{alert_name}}",
emoji: true,
},
},
{
type: "header",
text: {
type: "plain_text",
text: "{{#is_triggered}}🔴 Triggered{{/is_triggered}}{{#is_resolved}}🟢 Resolved{{/is_resolved}}",
emoji: true,
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: "{{description}}\n*Source:* {{source}}\n*Severity:* {{severity}}\n*Status:* {{status}}",
},
},
{
type: "section",
fields: [
{
type: "mrkdwn",
text: "*Metric:*\n{{metric}}",
},
{
type: "mrkdwn",
text: "*Current Value:*\n{{current_value}}",
},
{
type: "mrkdwn",
text: "*Threshold:*\n{{threshold}}",
},
{
type: "mrkdwn",
text: "*Timestamp:*\n<!date^{{timestamp_unix}}^{date} at {time}|{{timestamp}}>",
},
],
},
{
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "{{action_text}}",
},
url: "{{{action_url}}}",
style: "primary",
},
],
},
],
},
null,
2,
);
export const DefaultAPIEval = `(async function (statusCode, responseTime, responseRaw, modules) {
let statusCodeShort = Math.floor(statusCode/100);
if(statusCode == 429 || (statusCodeShort >=2 && statusCodeShort <= 3)) {
return {
status: 'UP',
latency: responseTime,
}
}
return {
status: 'DOWN',
latency: responseTime,
}
})`;
export const DefaultPingEval = `(async function (arrayOfPings) {
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
return acc + ping.latency;
}, 0);
let alive = arrayOfPings.reduce((acc, ping) => {
return acc && ping.alive;
}, true);
return {
status: alive ? 'UP' : 'DOWN',
latency: latencyTotal / arrayOfPings.length,
}
})`;
export const DefaultTCPEval = `(async function (arrayOfPings) {
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
return acc + ping.latency;
}, 0);
let alive = arrayOfPings.reduce((acc, ping) => {
return acc && ping.status === "open";
}, true);
return {
status: alive ? 'UP' : 'DOWN',
latency: latencyTotal / arrayOfPings.length,
}
})`;
export const ErrorSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="60" viewBox="0 0 120 60" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="30" cy="24" r="10"/>
<path d="M26 27h8"/>
<path d="M26 21h2"/>
<path d="M32 21h2"/>
<text x="80" y="29" text-anchor="middle" font-family="system-ui, sans-serif" font-size="14" fill="currentColor" font-weight="300">Not Found</text>
</svg>`;
+14 -10
View File
@@ -1,13 +1,17 @@
// @ts-ignore
export const analyticsEvent = (event, data) => {
// Do something with the event and data
document.dispatchEvent(
new CustomEvent("analyticsEvent", {
bubbles: true,
detail: {
event,
data
}
})
);
// Do something with the event and data
if (typeof document !== "undefined") {
event = "kener_" + event;
document.dispatchEvent(
new CustomEvent("analyticsEvent", {
bubbles: true,
detail: {
event,
data,
},
}),
);
}
};
+20 -6
View File
@@ -1,6 +1,8 @@
<script>
import { formatDistanceToNow, formatDistance } from "date-fns";
import { Settings, ArrowRight } from "lucide-svelte";
import Settings from "lucide-svelte/icons/settings";
import ArrowRight from "lucide-svelte/icons/arrow-right";
import { analyticsEvent } from "$lib/boringOne";
import * as Accordion from "$lib/components/ui/accordion";
import { l, f, fd, fdn } from "$lib/i18n/client";
import { base } from "$app/paths";
@@ -8,6 +10,7 @@
import { Tooltip } from "bits-ui";
import GMI from "$lib/components/gmi.svelte";
import { page } from "$app/stores";
import { marked } from "marked";
export let incident;
export let index;
export let lang;
@@ -78,7 +81,10 @@
<div class="col-span-12">
<Accordion.Root bind:value={index} class="accor">
<Accordion.Item value={accordionValue}>
<Accordion.Trigger class="px-4 hover:bg-muted hover:no-underline">
<Accordion.Trigger
class="rounded-md px-4 hover:bg-muted hover:no-underline"
on:click={() => analyticsEvent("incident_open", { incident_title: incident.title })}
>
<div class="w-full text-left hover:no-underline">
<p class="flex gap-x-2 text-xs font-semibold">
{#if incidentType == "INCIDENT"}
@@ -184,7 +190,11 @@
</time>
<div class="mb-4 text-sm font-normal">
{@html comment.comment}
<div
class="kener-md 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"
>
{@html marked.parse(comment.comment)}
</div>
</div>
</li>
{/each}
@@ -202,9 +212,13 @@
)}
</time>
<p class="mb-2 text-sm font-normal">
{comment.comment}
</p>
<div class="mb-2 text-sm font-normal">
<div
class="kener-md 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"
>
{@html marked.parse(comment.comment)}
</div>
</div>
</li>
{/each}
</ol>
+3 -2
View File
@@ -2,8 +2,9 @@
import { onMount } from "svelte";
import { base } from "$app/paths";
import moment from "moment";
import { Loader, ChevronLeft, ChevronRight } from "lucide-svelte";
import Loader from "lucide-svelte/icons/loader";
import ChevronLeft from "lucide-svelte/icons/chevron-left";
import ChevronRight from "lucide-svelte/icons/chevron-right";
export let data;
let pageNo = 1;
let limit = 20;
+233 -253
View File
@@ -1,271 +1,251 @@
<script>
import { onMount } from "svelte";
import { base } from "$app/paths";
import moment from "moment";
import { Button } from "$lib/components/ui/button";
import { Plus, X, Settings, Bell, Loader, Copy, Check } from "lucide-svelte";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { onMount } from "svelte";
import { base } from "$app/paths";
import moment from "moment";
import { Button } from "$lib/components/ui/button";
import Plus from "lucide-svelte/icons/plus";
import X from "lucide-svelte/icons/x";
import Settings from "lucide-svelte/icons/settings";
import Bell from "lucide-svelte/icons/bell";
import Loader from "lucide-svelte/icons/loader";
import Copy from "lucide-svelte/icons/copy";
import Check from "lucide-svelte/icons/check";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { page } from "$app/stores";
let apiKeys = [];
let loaderLoadingAll = false;
let loaderCreateNew = false;
let newAPIKeyName = "";
let newKeyResp = {};
let showCreateModal = false;
let apiKeys = [];
let loaderLoadingAll = false;
let loaderCreateNew = false;
let newAPIKeyName = "";
let newKeyResp = {};
let showCreateModal = false;
async function loadAPIKeys() {
loaderLoadingAll = true;
try {
let apiResp = await fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
action: "getAPIKeys",
data: {}
})
});
let resp = await apiResp.json();
apiKeys = resp;
} catch (error) {
alert("Error: " + error);
} finally {
loaderLoadingAll = false;
}
}
async function loadAPIKeys() {
loaderLoadingAll = true;
try {
let apiResp = await fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
action: "getAPIKeys",
data: {}
})
});
let resp = await apiResp.json();
apiKeys = resp;
} catch (error) {
alert("Error: " + error);
} finally {
loaderLoadingAll = false;
}
}
async function createNew() {
newKeyResp = {};
loaderCreateNew = true;
try {
let apiResp = await fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
action: "createNewApiKey",
data: {
name: newAPIKeyName
}
})
});
newKeyResp = await apiResp.json();
loadAPIKeys();
showCreateModal = false;
} catch (error) {
alert("Error: " + error);
} finally {
loaderCreateNew = false;
}
}
async function createNew() {
newKeyResp = {};
loaderCreateNew = true;
try {
let apiResp = await fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
action: "createNewApiKey",
data: {
name: newAPIKeyName
}
})
});
newKeyResp = await apiResp.json();
loadAPIKeys();
showCreateModal = false;
} catch (error) {
alert("Error: " + error);
} finally {
loaderCreateNew = false;
}
}
onMount(() => {
loadAPIKeys();
});
onMount(() => {
loadAPIKeys();
});
function copyKey() {
navigator.clipboard.writeText(newKeyResp.apiKey);
}
function copyKey() {
navigator.clipboard.writeText(newKeyResp.apiKey);
}
function updateStatus(apiKey) {
apiKey.status = apiKey.status == "ACTIVE" ? "INACTIVE" : "ACTIVE";
fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
action: "updateApiKeyStatus",
data: {
id: apiKey.id,
status: apiKey.status
}
})
});
}
function updateStatus(apiKey) {
apiKey.status = apiKey.status == "ACTIVE" ? "INACTIVE" : "ACTIVE";
fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
action: "updateApiKeyStatus",
data: {
id: apiKey.id,
status: apiKey.status
}
})
});
}
</script>
<div class="mt-4">
<div class="mb-4 flex justify-between">
<div>
{#if loaderLoadingAll}
<Loader class="mt-6 h-4 w-4 animate-spin" />
{/if}
</div>
<div>
<Button
on:click={(e) => {
showCreateModal = true;
}}
>
<Plus class="mr-2 h-4 w-4" /> Create New API Key
</Button>
</div>
</div>
<div class="mb-4 flex justify-between">
<div>
{#if loaderLoadingAll}
<Loader class="mt-6 h-4 w-4 animate-spin" />
{/if}
</div>
<div>
{#if $page.data.user.role != "member"}
<Button
on:click={(e) => {
showCreateModal = true;
}}
>
<Plus class="mr-2 h-4 w-4" /> Create New API Key
</Button>
{/if}
</div>
</div>
</div>
{#if !!newKeyResp && !!newKeyResp.apiKey}
<div class="my-4 rounded-lg border border-green-700 bg-green-800 bg-opacity-20 p-4">
<p class="font-medium">
<picture class="mr-1 inline-block">
<source
srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f389/512.webp"
type="image/webp"
/>
<img
src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f389/512.gif"
alt="🎉"
width="24"
height="24"
/>
</picture>
API Key Created
</p>
<p
class="relative my-2 rounded-sm border bg-card px-4 py-2 pr-8 font-mono text-sm font-medium"
>
{newKeyResp.apiKey}
<div class="my-4 rounded-lg border border-green-700 bg-green-800 bg-opacity-20 p-4">
<p class="font-medium">
<picture class="mr-1 inline-block">
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f389/512.webp" type="image/webp" />
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f389/512.gif" alt="🎉" width="24" height="24" />
</picture>
API Key Created
</p>
<p class="relative my-2 rounded-sm border bg-card px-4 py-2 pr-8 font-mono text-sm font-medium">
{newKeyResp.apiKey}
<Button
size="icon"
variant="ghost"
class="copybtn absolute right-2 top-2 h-5 w-5 p-1"
on:click={copyKey}
>
<Check class="check-btn absolute left-0 top-0 h-4 w-4 text-green-500" />
<Copy class="copy-btn absolute left-0 top-0 h-4 w-4 " />
</Button>
</p>
<p class="text-xs text-muted-foreground">
Your new API key has been created. It will be <b class="uppercase underline"
>not be shown again</b
>, so make sure to save it.
</p>
</div>
<Button size="icon" variant="ghost" class="copybtn absolute right-2 top-2 h-5 w-5 p-1" on:click={copyKey}>
<Check class="check-btn absolute left-0 top-0 h-4 w-4 text-green-500" />
<Copy class="copy-btn absolute left-0 top-0 h-4 w-4 " />
</Button>
</p>
<p class="text-xs text-muted-foreground">
Your new API key has been created. It will be <b class="uppercase underline">not be shown again</b>, so make sure
to save it.
</p>
</div>
{/if}
<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"
>Name</th
>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
>Key</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
>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-neutral-700">
{#each apiKeys as apiKey}
<tr>
<td
class="whitespace-nowrap px-6 py-4 text-sm font-medium text-gray-800 dark:text-neutral-200"
>
{apiKey.name}
</td>
<td
class="whitespace-nowrap px-6 py-4 text-xs font-semibold text-gray-800 dark:text-neutral-200"
>
{apiKey.masked_key.slice(-32)}
</td>
<td
class="whitespace-nowrap px-6 py-4 text-sm text-gray-800 dark:text-neutral-200"
>
{moment
.utc(apiKey.created_at, "YYYY-MM-DD HH:mm:ss")
.local()
.format("YYYY-MM-DD HH:mm:ss")}
</td>
<td
class="whitespace-nowrap px-6 py-4 text-xs font-semibold text-gray-800 dark:text-neutral-200"
>
<label class="inline-flex cursor-pointer items-center">
<input
type="checkbox"
value=""
class="peer sr-only"
checked={apiKey.status == "ACTIVE"}
on:change={() => {
updateStatus(apiKey);
}}
/>
<div
class="peer relative h-6 w-11 rounded-full bg-gray-200 after:absolute after:start-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rtl:peer-checked:after:-translate-x-full dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800"
></div>
</label>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
<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">Name</th
>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500">Key</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
>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-neutral-700">
{#each apiKeys as apiKey}
<tr>
<td class="whitespace-nowrap px-6 py-4 text-sm font-medium text-gray-800 dark:text-neutral-200">
{apiKey.name}
</td>
<td class="whitespace-nowrap px-6 py-4 text-xs font-semibold text-gray-800 dark:text-neutral-200">
{apiKey.masked_key.slice(-32)}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-800 dark:text-neutral-200">
{moment.utc(apiKey.created_at, "YYYY-MM-DD HH:mm:ss").local().format("YYYY-MM-DD HH:mm:ss")}
</td>
<td class="whitespace-nowrap px-6 py-4 text-xs font-semibold text-gray-800 dark:text-neutral-200">
<label class="inline-flex cursor-pointer items-center">
<input
type="checkbox"
disabled={$page.data.user.role == "member"}
value=""
class="peer sr-only"
checked={apiKey.status == "ACTIVE"}
on:change={() => {
updateStatus(apiKey);
}}
/>
<div
class="peer relative h-6 w-11 rounded-full bg-gray-200 after:absolute after:start-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 peer-disabled:opacity-55 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800 rtl:peer-checked:after:-translate-x-full"
></div>
</label>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
</div>
{#if showCreateModal}
<div
class="moldal-container fixed left-0 top-0 z-30 h-screen w-full bg-card bg-opacity-30 backdrop-blur-sm"
>
<div
class="absolute left-1/2 top-1/2 h-fit w-full max-w-xl -translate-x-1/2 -translate-y-1/2 rounded-md border bg-background shadow-lg backdrop-blur-lg"
>
<Button
variant="ghost"
on:click={() => {
showCreateModal = false;
}}
class="absolute right-2 top-2 z-40 h-6 w-6 rounded-full border bg-background p-1"
>
<X class="h-4 w-4 text-muted-foreground" />
</Button>
<div class="content px-4 py-4">
<h2 class="text-lg font-semibold">Create a new API Key</h2>
<p class="text-xs text-muted-foreground">
API keys are used to authenticate your requests to the API. They are unique to
your account and should be kept secret.
</p>
<hr class="my-4" />
<form on:submit|preventDefault={createNew}>
<div class="flex flex-col gap-4">
<div>
<Label for="newAPIKeyName">Name</Label>
<Input
bind:value={newAPIKeyName}
class="mt-2"
type="text"
id="newAPIKeyName"
placeholder="eg. My API Key"
/>
</div>
</div>
<div class="mt-4 flex justify-end">
<Button variant="secondary" type="submit" disabled={loaderCreateNew}>
Create
{#if loaderCreateNew}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</form>
</div>
</div>
</div>
<div class="moldal-container fixed left-0 top-0 z-30 h-screen w-full bg-card bg-opacity-30 backdrop-blur-sm">
<div
class="absolute left-1/2 top-1/2 h-fit w-full max-w-xl -translate-x-1/2 -translate-y-1/2 rounded-md border bg-background shadow-lg backdrop-blur-lg"
>
<Button
variant="ghost"
on:click={() => {
showCreateModal = false;
}}
class="absolute right-2 top-2 z-40 h-6 w-6 rounded-full border bg-background p-1"
>
<X class="h-4 w-4 text-muted-foreground" />
</Button>
<div class="content px-4 py-4">
<h2 class="text-lg font-semibold">Create a new API Key</h2>
<p class="text-xs text-muted-foreground">
API keys are used to authenticate your requests to the API. They are unique to your account and should be kept
secret.
</p>
<hr class="my-4" />
<form on:submit|preventDefault={createNew}>
<div class="flex flex-col gap-4">
<div>
<Label for="newAPIKeyName">Name</Label>
<Input
bind:value={newAPIKeyName}
class="mt-2"
type="text"
id="newAPIKeyName"
required
placeholder="eg. My API Key"
/>
</div>
</div>
<div class="mt-4 flex justify-end">
<Button variant="secondary" type="submit" disabled={loaderCreateNew}>
Create
{#if loaderCreateNew}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</form>
</div>
</div>
</div>
{/if}
+146 -57
View File
@@ -8,10 +8,20 @@
import * as Select from "$lib/components/ui/select";
import locales from "$lib/locales/locales.json?raw";
import GMI from "$lib/components/gmi.svelte";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { Loader, X, Plus, Info, Play } from "lucide-svelte";
import Loader from "lucide-svelte/icons/loader";
import X from "lucide-svelte/icons/x";
import Plus from "lucide-svelte/icons/plus";
import Info from "lucide-svelte/icons/info";
import Play from "lucide-svelte/icons/play";
import { Tooltip } from "bits-ui";
import CodeMirror from "svelte-codemirror-editor";
import { html } from "@codemirror/lang-html";
import { githubLight, githubDark } from "@uiw/codemirror-theme-github";
import { mode } from "mode-watcher";
export let data;
let formStateHero = "idle";
@@ -30,6 +40,15 @@
let homeIncidentCount = 10;
let homeIncidentStartTimeWithin = 30;
let incidentGroupView = "EXPAND_FIRST";
let kenerTheme = "default";
let availableThemes = [
{ name: "Default", value: "default" },
{ name: "Mono", value: "mono" },
{ name: "Sunset", value: "sunset" },
{ name: "Forest", value: "forest" },
{ name: "Ocean", value: "ocean" }
];
let nav = [];
let categories = [];
@@ -70,6 +89,9 @@
if (data.siteData.footerHTML) {
footerHTML = data.siteData.footerHTML;
}
if (data.siteData.kenerTheme) {
kenerTheme = data.siteData.kenerTheme;
}
if (data.siteData.tzToggle) {
tzToggle = data.siteData.tzToggle;
}
@@ -87,8 +109,9 @@
}
});
}
let heroError = "";
async function formSubmitHero() {
heroError = "";
formStateHero = "loading";
let resp = await storeSiteData({
hero: JSON.stringify(hero)
@@ -96,6 +119,19 @@
//print data
let data = await resp.json();
formStateHero = "idle";
if (data.error) {
heroError = data.error;
return;
}
saveTheme();
}
async function saveTheme() {
let resp = await storeSiteData({
kenerTheme: kenerTheme
});
//print data
let data = await resp.json();
if (data.error) {
alert(data.error);
return;
@@ -117,7 +153,10 @@
return;
}
}
let categoriesErrorMessage = "";
async function formSubmitCategories() {
categoriesErrorMessage = "";
formStateCategories = "loading";
let resp = await storeSiteData({
categories: JSON.stringify(categories)
@@ -126,11 +165,14 @@
let data = await resp.json();
formStateCategories = "idle";
if (data.error) {
alert(data.error);
categoriesErrorMessage = data.error;
return;
}
}
let footerErrorMessage = "";
async function formSubmitFooter() {
footerErrorMessage = "";
formStateFooter = "loading";
let resp = await storeSiteData({
footerHTML: footerHTML
@@ -139,11 +181,14 @@
let data = await resp.json();
formStateFooter = "idle";
if (data.error) {
alert(data.error);
footerErrorMessage = data.error;
return;
}
}
let incidentErrorMessage = "";
async function formSubmitIncident() {
incidentErrorMessage = "";
formStateIncident = "loading";
let resp = await storeSiteData({
homeIncidentCount: homeIncidentCount,
@@ -154,7 +199,7 @@
let data = await resp.json();
formStateIncident = "idle";
if (data.error) {
alert(data.error);
incidentErrorMessage = data.error;
return;
}
}
@@ -220,7 +265,9 @@
i18n.defaultLocale = e.value;
}
let i18nErrorMessage = "";
async function formSubmiti18n() {
i18nErrorMessage = "";
formStatei18n = "loading";
let resp = await storeSiteData({
i18n: JSON.stringify(i18n),
@@ -230,7 +277,7 @@
let data = await resp.json();
formStatei18n = "idle";
if (data.error) {
alert(data.error);
i18nErrorMessage = data.error;
return;
}
}
@@ -242,44 +289,16 @@
<Card.Description>Configure the hero section of your site.</Card.Description>
</Card.Header>
<Card.Content>
<form class="mx-auto mt-4 space-y-4" on:submit|preventDefault={formSubmitHero}>
<form class="mx-auto mt-4 flex flex-col gap-y-4" on:submit|preventDefault={formSubmitHero}>
<div class="flex w-full flex-row justify-evenly gap-2">
<div class="w-full">
<Label for="hero_title">
Title
<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"
>
You can set the title of the hero section here. This will be displayed on the top of the page.
</div>
</Tooltip.Content>
</Tooltip.Root>
</Label>
<Label for="hero_title">Title</Label>
<Input bind:value={hero.title} class="mt-2" type="text" id="hero_title" placeholder="Status Page for Kener" />
</div>
</div>
<div class="flex w-full flex-row justify-evenly gap-2">
<div class="w-full">
<Label for="hero_subtitle">
Subtitle
<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"
>
You can set the subtitle of the hero section here. This will be displayed below the title.
</div>
</Tooltip.Content>
</Tooltip.Root>
</Label>
<Label for="hero_subtitle">Subtitle</Label>
<Input
bind:value={hero.subtitle}
class="mt-2"
@@ -289,9 +308,49 @@
/>
</div>
</div>
<div class="flex w-full justify-end">
<div class="flex flex-col gap-y-2">
<div>
<Label>Color</Label>
</div>
<div class="flex flex-row gap-x-2 rounded-md border p-2">
<div class="w-3/4 pt-2">
<span class="kener-theme-{kenerTheme}-hero-h1 font-medium">{hero.title}</span>
</div>
<div class="flex w-1/4 justify-end">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="outline">
{availableThemes.find((el) => el.value === kenerTheme)?.name || "Mono"}
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{#each availableThemes as theme}
<DropdownMenu.Group>
<DropdownMenu.Item>
<Button
variant="ghost"
class="h-6 w-full justify-start text-xs"
on:click={() => {
kenerTheme = theme.value;
}}
>
{theme.name}
</Button>
</DropdownMenu.Item>
</DropdownMenu.Group>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</div>
</div>
<div class="flex w-full justify-end gap-x-2">
{#if !!heroError}
<div class="py-2 text-sm font-medium text-destructive">{heroError}</div>
{/if}
<Button type="submit" disabled={formStateHero === "loading"}>
Save
Save Hero Section
{#if formStateHero === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
@@ -351,7 +410,7 @@
</div>
<div class="pt-8">
<Button type="submit" disabled={formStateIncident === "loading"}>
Save
Save Incidents Settings
{#if formStateIncident === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
@@ -362,6 +421,9 @@
Translates to "Show {homeIncidentCount} incidents that have started in the last {homeIncidentStartTimeWithin}
days in the past or going to start in {homeIncidentStartTimeWithin} days in the future"
</p>
{#if !!incidentErrorMessage}
<p class="text-sm font-medium text-destructive">{incidentErrorMessage}</p>
{/if}
</form>
</Card.Content>
</Card.Root>
@@ -470,10 +532,10 @@
</div>
<div class="flex w-full justify-end" style="margin-top:32px">
<p class="px-4 pt-1">
<span class="text-xs text-red-500"> {navErrorMessage} </span>
<span class="text-sm font-medium text-destructive"> {navErrorMessage} </span>
</p>
<Button type="submit" disabled={formStateHero === "loading"}>
Save
Save Navigation
{#if formStateHero === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
@@ -547,9 +609,12 @@
Allow users to switch timezones
</label>
</p>
<div class="flex w-full justify-end">
<div class="flex w-full justify-end gap-x-2">
{#if !!i18nErrorMessage}
<div class="py-2 text-sm font-medium text-destructive">{i18nErrorMessage}</div>
{/if}
<Button type="submit" disabled={formStatei18n === "loading"}>
Save
Save Languages
{#if formStatei18n === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
@@ -607,9 +672,12 @@
<Plus class="h-4 w-4 " />
</Button>
</div>
<div class="flex w-full justify-end">
<div class="flex w-full justify-end gap-x-2">
{#if !!categoriesErrorMessage}
<div class="py-2 text-sm font-medium text-destructive">{categoriesErrorMessage}</div>
{/if}
<Button type="submit" disabled={formStateCategories === "loading"}>
Save
Save Categories
{#if formStateCategories === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
@@ -628,21 +696,42 @@
<form class="mx-auto mt-4 space-y-4" on:submit|preventDefault={formSubmitFooter}>
<div class="flex w-full flex-row justify-evenly gap-2">
<div class="w-full">
<textarea
bind:value={footerHTML}
class="mt-2 h-48 w-full rounded-sm border p-2"
placeholder="eg. <p>Powered by Kener</p>"
></textarea>
<div class="overflow-hidden rounded-md">
<CodeMirror
bind:value={footerHTML}
lang={html()}
theme={$mode == "dark" ? githubDark : githubLight}
styles={{
"&": {
width: "100%",
maxWidth: "100%",
height: "320px",
border: "1px solid hsl(var(--border) / var(--tw-border-opacity))",
borderRadius: "0.375rem"
}
}}
/>
</div>
</div>
</div>
<div class="flex w-full justify-end">
<Button type="submit" disabled={formStateFooter === "loading"}>
Save
{#if formStateFooter === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
<div class="flex w-full justify-between gap-x-2">
<div>
<Button variant="outline" class="mt-2" on:click={() => (footerHTML = data.defaultFooterHTML)}>
<span class="ml-2">Reset to default</span>
</Button>
</div>
<div class="flex flex-row justify-end gap-x-2">
{#if !!footerErrorMessage}
<div class="py-2 text-sm font-medium text-destructive">{footerErrorMessage}</div>
{/if}
</Button>
<Button type="submit" disabled={formStateFooter === "loading"}>
Save Custom Footer
{#if formStateFooter === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</div>
</form>
</Card.Content>
+147 -22
View File
@@ -1,15 +1,24 @@
<script>
import { Button } from "$lib/components/ui/button";
import { Plus, X, Loader, Clipboard, Check } from "lucide-svelte";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { clickOutsideAction, slide } from "svelte-legos";
import { ChevronRight, Trash } from "lucide-svelte";
import Plus from "lucide-svelte/icons/plus";
import X from "lucide-svelte/icons/x";
import Loader from "lucide-svelte/icons/loader";
import Clipboard from "lucide-svelte/icons/clipboard";
import Check from "lucide-svelte/icons/check";
import ChevronRight from "lucide-svelte/icons/chevron-right";
import Trash from "lucide-svelte/icons/trash";
import { base } from "$app/paths";
import * as Select from "$lib/components/ui/select";
import { createEventDispatcher } from "svelte";
import GMI from "$lib/components/gmi.svelte";
import { page } from "$app/stores";
import CodeMirror from "svelte-codemirror-editor";
import { javascript } from "@codemirror/lang-javascript";
import { githubLight, githubDark } from "@uiw/codemirror-theme-github";
import { mode } from "mode-watcher";
import {
allRecordTypes,
ValidateIpAddress,
@@ -100,8 +109,8 @@
return false;
}
try {
// let evalResp = await eval(newMonitor.apiConfig.eval + `(200, 1000, "e30=")`);
new Function(ev);
return true; // The code is valid
} catch (error) {
invalidFormMessage = error.message + " in eval.";
@@ -179,6 +188,12 @@
invalidFormMessage = `Host ${hosts[i].host} is not of type ${hosts[i].type}`;
return;
}
//validating timeout for ping
if (!!hosts[i].timeout && isNaN(hosts[i].timeout)) {
invalidFormMessage = "Timeout should be a number";
return;
}
hosts[i].timeout = Number(hosts[i].timeout);
//validating timeout
if (hosts[i].timeout < 1) {
invalidFormMessage = "Timeout should be greater than 0";
@@ -213,6 +228,12 @@
invalidFormMessage = `Host ${hosts[i].host} is not of type ${hosts[i].type}`;
return;
}
//validating timeout for tcp
if (!!hosts[i].timeout && isNaN(hosts[i].timeout)) {
invalidFormMessage = "Timeout should be a number";
return;
}
hosts[i].timeout = Number(hosts[i].timeout);
//validating timeout
if (hosts[i].timeout < 1) {
invalidFormMessage = "Timeout should be greater than 0";
@@ -410,6 +431,35 @@
pg: "PostgreSQL",
mysql2: "MySQL"
};
let deleteMonitorConfirmText = "";
let deletingMonitor = false;
async function deleteMonitor() {
if (deleteMonitorConfirmText != `delete ${newMonitor.tag}`) {
invalidFormMessage = "Please type the correct text to delete the monitor";
return;
}
deletingMonitor = true;
try {
let data = await fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ action: "deleteMonitor", data: { tag: newMonitor.tag } })
});
let resp = await data.json();
if (resp.error) {
invalidFormMessage = resp.error;
} else {
dispatch("closeModal", {});
}
} catch (error) {
invalidFormMessage = "Error while deleting monitor";
} finally {
deletingMonitor = false;
}
}
</script>
<div class="fixed left-0 top-0 z-50 h-screen w-screen bg-card bg-opacity-20 backdrop-blur-sm">
@@ -718,6 +768,18 @@
></textarea>
</div>
{/if}
<div class="col-span-6">
<label class="cursor-pointer">
<input
type="checkbox"
on:change={(e) => {
newMonitor.apiConfig.allowSelfSignedCert = e.target.checked;
}}
checked={newMonitor.apiConfig.allowSelfSignedCert}
/>
<span class="ml-2 text-sm">Allow Self Signed Certificate</span>
</label>
</div>
<div class="col-span-6">
<Label for="eval">Eval</Label>
<p class="my-1 text-xs text-muted-foreground">
@@ -728,12 +790,23 @@
href="https://kener.ing/docs/monitors-api#eval">Read the docs</a
> to learn
</p>
<textarea
bind:value={newMonitor.apiConfig.eval}
id="eval"
class="h-96 w-full rounded-sm border p-2"
placeholder="Leave blank or write a custom eval function"
></textarea>
<div class="overflow-hidden rounded-md">
<CodeMirror
bind:value={newMonitor.apiConfig.eval}
lang={javascript()}
theme={$mode == "dark" ? githubDark : githubLight}
styles={{
"&": {
width: "100%",
maxWidth: "100%",
height: "18rem",
border: "1px solid hsl(var(--border) / var(--tw-border-opacity))",
borderRadius: "0.375rem"
}
}}
/>
</div>
</div>
</div>
{:else if newMonitor.monitor_type == "PING"}
@@ -823,12 +896,23 @@
href="https://kener.ing/docs/monitors-ping#eval">Read the docs</a
> to learn
</p>
<textarea
bind:value={newMonitor.pingConfig.pingEval}
id="pingEval"
class="h-96 w-full rounded-sm border p-2"
placeholder="Leave blank or write a custom eval function"
></textarea>
<div class="overflow-hidden rounded-md">
<CodeMirror
bind:value={newMonitor.pingConfig.pingEval}
lang={javascript()}
theme={$mode == "dark" ? githubDark : githubLight}
styles={{
"&": {
width: "100%",
maxWidth: "100%",
height: "18rem",
border: "1px solid hsl(var(--border) / var(--tw-border-opacity))",
borderRadius: "0.375rem"
}
}}
/>
</div>
</div>
</div>
</div>
@@ -900,7 +984,7 @@
];
}}
>
<Plus class="h-5 w-5" /> Add New
<Plus class="mr-1 h-5 w-5" /> Add New
</Button>
</div>
</div>
@@ -914,12 +998,22 @@
href="https://kener.ing/docs/monitors-tcp#eval">Read the docs</a
> to learn
</p>
<textarea
bind:value={newMonitor.tcpConfig.tcpEval}
id="tcpEval"
class="h-96 w-full rounded-sm border p-2"
placeholder="Leave blank or write a custom eval function"
></textarea>
<div class="overflow-hidden rounded-md">
<CodeMirror
bind:value={newMonitor.tcpConfig.tcpEval}
lang={javascript()}
theme={$mode == "dark" ? githubDark : githubLight}
styles={{
"&": {
width: "100%",
maxWidth: "100%",
height: "22rem",
border: "1px solid hsl(var(--border) / var(--tw-border-opacity))",
borderRadius: "0.375rem"
}
}}
/>
</div>
</div>
</div>
</div>
@@ -1211,6 +1305,37 @@
</div>
</div>
{/if}
{#if !!newMonitor.id}
<div class="mt-4 flex">
<div class="flex w-full flex-col rounded-md border border-destructive p-2">
<div class="w-full">
<h2 class="flex justify-between text-lg">
<span class="text-destructive">Danger Zone</span>
</h2>
<p class="text-sm">Deleting a monitor is irreversible. Please be sure before deleting.</p>
</div>
<form class="flex gap-x-2" on:submit|preventDefault={deleteMonitor}>
<div class="mt-1 flex flex-col gap-y-2">
<Label for="deleteMonitor">
Type <i class="text-destructive">delete {newMonitor.tag}</i> to confirm
</Label>
<Input bind:value={deleteMonitorConfirmText} id="deleteMonitor" required />
</div>
<Button
variant="destructive"
type="submit"
class="mt-7"
disabled={deleteMonitorConfirmText != `delete ${newMonitor.tag}`}
>
Delete
{#if deletingMonitor}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</form>
</div>
</div>
{/if}
</div>
<div class="absolute bottom-0 grid h-16 w-full grid-cols-6 justify-end gap-2 border-t p-3 pr-6">
<div class="col-span-5 py-2.5">
+106 -59
View File
@@ -1,6 +1,13 @@
<script>
import { Button } from "$lib/components/ui/button";
import { Plus, X, Settings, Bell, Loader, ArrowDownUp, Grip, ExternalLink } from "lucide-svelte";
import Plus from "lucide-svelte/icons/plus";
import X from "lucide-svelte/icons/x";
import Settings from "lucide-svelte/icons/settings";
import Bell from "lucide-svelte/icons/bell";
import Loader from "lucide-svelte/icons/loader";
import ArrowDownUp from "lucide-svelte/icons/arrow-down-up";
import Grip from "lucide-svelte/icons/grip";
import ExternalLink from "lucide-svelte/icons/external-link";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { base } from "$app/paths";
@@ -13,6 +20,8 @@
import { dndzone } from "svelte-dnd-action";
import { flip } from "svelte/animate";
import GMI from "$lib/components/gmi.svelte";
import { page } from "$app/stores";
import { DefaultAPIEval, DefaultTCPEval, DefaultPingEval } from "$lib/anywhere.js";
export let categories = [];
export let colorDown = "#777";
@@ -70,16 +79,17 @@
headers: [],
body: "",
timeout: 10000,
eval: "",
hideURLForGet: "NO"
eval: DefaultAPIEval,
hideURLForGet: "NO",
allowSelfSignedCert: false
},
tcpConfig: {
hosts: [], //{timeout: 1000, host: "", type:""}
tcpEval: ""
tcpEval: DefaultTCPEval
},
pingConfig: {
hosts: [], //{timeout: 1000, host: "", count: "", type:""}
pingEval: ""
pingEval: DefaultPingEval
},
dnsConfig: {
host: "",
@@ -219,30 +229,37 @@
});
let shareMenusToggle = false;
let monitorTriggers = {
down_trigger: {
failureThreshold: 1,
trigger_type: "DOWN",
successThreshold: 1,
description: "The monitor is down",
createIncident: "NO",
active: false,
triggers: [],
severity: "critical"
},
degraded_trigger: {
failureThreshold: 1,
trigger_type: "DEGRADED",
successThreshold: 1,
active: false,
description: "The monitor is degraded",
createIncident: "NO",
triggers: [],
severity: "warning"
}
};
function initMonitorTriggers() {
monitorTriggers = {
down_trigger: {
failureThreshold: 1,
trigger_type: "DOWN",
successThreshold: 1,
description: "The monitor is down",
createIncident: "NO",
active: false,
triggers: [],
severity: "critical"
},
degraded_trigger: {
failureThreshold: 1,
trigger_type: "DEGRADED",
successThreshold: 1,
active: false,
description: "The monitor is degraded",
createIncident: "NO",
triggers: [],
severity: "warning"
}
};
}
let monitorTriggers;
initMonitorTriggers();
let saveTriggerError = "";
async function saveTriggers() {
saveTriggerError = "";
let data = {
id: currentAlertMonitor.id,
down_trigger: JSON.stringify(monitorTriggers.down_trigger),
@@ -258,16 +275,23 @@
},
body: JSON.stringify({ action: "updateMonitorTriggers", data })
});
let resp = await apiResp.json();
if (resp.error) {
saveTriggerError = resp.error;
} else {
shareMenusToggle = false;
loadData();
}
} catch (error) {
alert("Error: " + error);
saveTriggerError = "Error while saving triggers";
} finally {
formState = "idle";
loadData();
shareMenusToggle = false;
}
}
let currentAlertMonitor;
function openAlertMenu(m) {
initMonitorTriggers();
currentAlertMonitor = m;
if (m.down_trigger) {
monitorTriggers.down_trigger = m.down_trigger;
@@ -278,15 +302,27 @@
shareMenusToggle = true;
}
const flipDurationMs = 200;
let orderErrorMessage = "";
function handleSort(e) {
dropTargetStyle = {
border: "1px solid transparent"
};
monitors = e.detail.items;
monitorSort = monitors.map((m) => m.id);
storeSiteData({
monitorSort: JSON.stringify(monitorSort)
});
})
.then(async (resp) => {
let data = await resp.json();
if (data.error) {
orderErrorMessage = data.error;
}
})
.catch((error) => {
orderErrorMessage = "Error while saving order";
});
}
let dropTargetStyle;
let draggableMenu = false;
@@ -360,6 +396,9 @@
</div>
{/each}
</div>
{#if !!orderErrorMessage}
<p class="py-2 text-sm font-medium text-destructive">{orderErrorMessage}</p>
{/if}
</div>
</div>
</div>
@@ -423,17 +462,19 @@
{/if}
</div>
</div>
<div>
{#if status == "ACTIVE"}
<Button size="icon" variant="secondary" on:click={() => (draggableMenu = !draggableMenu)}>
<ArrowDownUp class=" " />
{#if $page.data.user.role != "member"}
<div>
{#if status == "ACTIVE"}
<Button size="icon" variant="secondary" on:click={() => (draggableMenu = !draggableMenu)}>
<ArrowDownUp class=" " />
</Button>
{/if}
<Button on:click={showAddMonitorSheet}>
<Plus class="mr-2 inline h-6 w-6" />
Add Monitor
</Button>
{/if}
<Button on:click={showAddMonitorSheet}>
<Plus class="mr-2 inline h-6 w-6" />
Add Monitor
</Button>
</div>
</div>
{/if}
</div>
<div class="mt-4">
@@ -452,7 +493,7 @@
<div class="absolute right-2 top-0.5 flex gap-x-1">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="secondary" class="h-8 p-2 text-xs" on:click={() => testMonitor(i)}>TEST</Button>
<Button variant="secondary" class="h-8 p-2 text-xs" on:click={() => testMonitor(i)}>Test Monitor</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content class="max-w-md">
<DropdownMenu.Group>
@@ -468,7 +509,7 @@
<div class="text-red-500">
{monitor.testResult.error}
</div>
{:else if !!monitor.testResult.status && !!monitor.testResult.latency}
{:else if monitor.testResult.status != undefined && monitor.testResult.latency != undefined}
<p class="text-muted-foreground">
Status: <span class="text-api-{monitor.testResult.status.toLowerCase()}"
>{monitor.testResult.status}</span
@@ -482,22 +523,25 @@
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Button
variant="secondary"
class="h-8 w-8 p-2 {monitor.down_trigger?.active || monitor.degraded_trigger?.active
? 'text-yellow-500'
: ''}"
on:click={() => openAlertMenu(monitor)}
>
<Bell class="inline h-4 w-4" />
</Button>
{#if $page.data.user.role != "member"}
<Button
variant="secondary"
class="h-8 w-8 p-2 {monitor.down_trigger?.active || monitor.degraded_trigger?.active
? 'text-yellow-500'
: ''}"
on:click={() => openAlertMenu(monitor)}
>
<Bell class="inline h-4 w-4" />
</Button>
{/if}
<Button variant="secondary" class="h-8 w-8 p-2" rel="external" href="{base}/?monitor={monitor.tag}">
<ExternalLink class="inline h-4 w-4" />
</Button>
<Button variant="secondary" class="h-8 w-8 p-2" href="#{monitor.tag}">
<Settings class="inline h-4 w-4" />
</Button>
{#if $page.data.user.role != "member"}
<Button variant="secondary" class="h-8 w-8 p-2" href="#{monitor.tag}">
<Settings class="inline h-4 w-4" />
</Button>
{/if}
</div>
</Card.Header>
<Card.Content>
@@ -677,9 +721,12 @@
<hr class="my-4" />
{/each}
<div class="flex justify-end">
<Button class="w-full" on:click={saveTriggers} disabled={formState === "loading"}>
Save
<div class="flex justify-end gap-x-2">
{#if !!saveTriggerError}
<div class="py-2 text-sm font-medium text-destructive">{saveTriggerError}</div>
{/if}
<Button class="" on:click={saveTriggers} disabled={formState === "loading"}>
Save Alerts
{#if formState === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
+324 -205
View File
@@ -1,221 +1,340 @@
<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 } from "lucide-svelte";
import autoAnimate from "@formkit/auto-animate";
import { siteDataExtractFromDb, storeSiteData } from "$lib/clientTools.js";
import { base } from "$app/paths";
import { Loader, Info } 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 autoAnimate from "@formkit/auto-animate";
import { siteDataExtractFromDb, storeSiteData } from "$lib/clientTools.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { base } from "$app/paths";
import Plus from "lucide-svelte/icons/plus";
import X from "lucide-svelte/icons/x";
import Check from "lucide-svelte/icons/check";
import Loader from "lucide-svelte/icons/loader";
import Info from "lucide-svelte/icons/info";
import { Tooltip } from "bits-ui";
export let data;
export let data;
let metaTags = [];
function addNewRow() {
metaTags = [
...metaTags,
{
key: "",
value: ""
}
];
}
let analyticsSupported = [
{
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"
}
];
function removeRow(i) {
metaTags = metaTags.filter((_, index) => index !== i);
}
let metaTags = [];
function addNewRow() {
metaTags = [
...metaTags,
{
key: "",
value: ""
}
];
}
metaTags = siteDataExtractFromDb(data.siteData, {
metaTags: JSON.stringify(metaTags)
}).metaTags;
function removeRow(i) {
metaTags = metaTags.filter((_, index) => index !== i);
}
let analytics = siteDataExtractFromDb(data.siteData, {
analytics: []
}).analytics;
metaTags = siteDataExtractFromDb(data.siteData, {
metaTags: JSON.stringify(metaTags)
}).metaTags;
//merge analyticsSupported with analytics
analytics = analyticsSupported.map((supported) => {
let found = analytics.find((a) => a.type === supported.type);
if (found) {
return found;
}
return supported;
});
let formState = "idle";
let formStateAnalytics = "idle";
let formState = "idle";
let formStateAnalytics = "idle";
async function formSubmit() {
formState = "loading";
let resp = await storeSiteData({
metaTags: JSON.stringify(metaTags)
});
//print data
let data = await resp.json();
formState = "idle";
if (data.error) {
alert(data.error);
return;
}
}
let metaError = "";
async function formSubmit() {
metaError = "";
formState = "loading";
let resp = await storeSiteData({
metaTags: JSON.stringify(metaTags)
});
//print data
let data = await resp.json();
formState = "idle";
if (data.error) {
metaError = data.error;
return;
}
}
let seoError = "";
async function formSubmitAnalytics() {
seoError = "";
formStateAnalytics = "loading";
async function formSubmitAnalytics() {
formStateAnalytics = "loading";
let resp = await storeSiteData({
analytics: JSON.stringify(analytics)
});
//print data
let data = await resp.json();
formStateAnalytics = "idle";
if (data.error) {
alert(data.error);
return;
}
}
let dataToSave = {};
selectedAnalytics.requirements.forEach((req) => {
dataToSave[req.label] = req.value;
});
let finalData = {};
finalData[selectedAnalytics.key] = JSON.stringify({
requirements: dataToSave,
isEnabled: selectedAnalytics.isEnabled
});
let resp = await storeSiteData(finalData);
//print data
let data = await resp.json();
formStateAnalytics = "idle";
if (data.error) {
seoError = data.error;
return;
} else {
selectedAnalytics.activeInSite = selectedAnalytics.isEnabled;
//update activeInSite in analyticsDropdownItems also
let index = analyticsDropdownItems.findIndex((item) => item.label === selectedAnalytics.label);
analyticsDropdownItems[index].activeInSite = true;
}
}
let selectedAnalytics;
let analyticsDropdownItems = [
{
label: "Google Analytics",
logo: `/ga.png`,
key: "analytics.googleTagManager",
isEnabled: false,
requirements: [
{
label: "Measurement ID",
type: "text",
placeholder: "G-S05E5E5E5E5",
required: true,
value: ""
}
]
},
{
label: "Plausible",
logo: `/plausible.png`,
key: "analytics.plausible",
isEnabled: false,
requirements: [
{
label: "Domain",
type: "text",
placeholder: "kener.ing",
required: true,
value: new URL(data.siteData.siteURL).hostname
},
{
label: "API",
type: "text",
placeholder: "https://plausible.io/api/event",
required: true,
value: "https://plausible.io/api/event"
},
{
label: "Script Source",
type: "text",
placeholder: "https://plausible.io/js/script.pageview-props.tagged-events.js",
required: true,
value: "https://plausible.io/js/script.pageview-props.tagged-events.js"
}
]
},
{
label: "MixPanel",
logo: `/mixpanel.png`,
key: "analytics.mixpanel",
isEnabled: false,
requirements: [
{
label: "Project Token",
type: "text",
placeholder: "YOUR_PROJECT_TOKEN",
required: true,
value: ""
}
]
},
{
label: "Amplitude",
logo: `/amplitude.png`,
key: "analytics.amplitude",
isEnabled: false,
requirements: [
{
label: "Amplitude API Key",
type: "text",
placeholder: "API key for your Amplitude project",
required: true,
value: ""
}
]
},
{
label: "Microsoft Clarity",
logo: `/clarity.png`,
key: "analytics.clarity",
isEnabled: false,
requirements: [
{
label: "Project ID",
type: "text",
placeholder: "Project ID for your Microsoft Clarity project",
required: true,
value: ""
}
]
}
];
analyticsDropdownItems = analyticsDropdownItems.map((item) => {
let dbData = data.siteData[item.key];
if (!!dbData) {
item.isEnabled = dbData.isEnabled;
item.requirements.forEach((req) => {
req.value = dbData.requirements[req.label];
});
item.activeInSite = true;
}
return item;
});
selectedAnalytics = analyticsDropdownItems[0];
</script>
<Card.Root class="mt-4">
<Card.Header class="border-b">
<Card.Title>Analytics</Card.Title>
<Card.Description>
Add your analytics ID/Key here. You can add multiple analytics providers.
</Card.Description>
</Card.Header>
<Card.Content>
<form class="mx-auto mt-4 space-y-4" on:submit|preventDefault={formSubmitAnalytics}>
<div class="grid grid-cols-2 gap-2">
{#each analytics as analytic}
<div class="col-span-1">
<Label for={analytic.type}>
{analytic.name}
<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-gray-800 p-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
>
Add your {analytic.name} ID/Key here.
</div>
</Tooltip.Content>
</Tooltip.Root>
</Label>
<Input
bind:value={analytic.id}
class="mt-2"
type="text"
id={analytic.type}
placeholder="{analytic.type} ID/Key"
/>
</div>
{/each}
</div>
<Card.Header class="border-b">
<Card.Title>Analytics</Card.Title>
<Card.Description>Add your analytics ID/Key here. You can add multiple analytics providers.</Card.Description>
</Card.Header>
<Card.Content>
<div class="mt-4 grid grid-cols-4 overflow-hidden rounded-md border">
<div class="col-span-1 flex flex-col border-r">
{#each analyticsDropdownItems as item}
<Button
variant={!!selectedAnalytics && selectedAnalytics.label === item.label ? "primary" : "ghost"}
class="flex items-center justify-between gap-x-2 rounded-none border-b text-sm last:border-none"
on:click={() => (selectedAnalytics = item)}
>
<div class="flex flex-row gap-x-3">
<img src={base + item.logo} class="w-5" alt={item.label} />
<span>{item.label}</span>
</div>
{#if item.activeInSite && item.isEnabled}
<div class="rounded-sm">
<span class="relative flex size-2">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"
></span>
<span class="relative inline-flex size-2 rounded-full bg-green-500"></span>
</span>
</div>
{/if}
</Button>
{/each}
</div>
<div class="flex w-full justify-end">
<Button type="submit" disabled={formStateAnalytics === "loading"}>
Save
{#if formStateAnalytics === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</form>
</Card.Content>
<form on:submit|preventDefault={formSubmitAnalytics} class="col-span-3 flex flex-col justify-between p-3 py-2">
{#if !!selectedAnalytics}
<div>
<div class="mb-2 text-sm font-medium text-muted-foreground">
Add details for your {selectedAnalytics.label} account
</div>
</div>
<div class="grid grid-cols-4 gap-4">
{#each selectedAnalytics.requirements as req}
<div class="col-span-2 flex flex-col gap-y-2">
<Label for={req.label}>{req.label}</Label>
<Input
bind:value={req.value}
class=""
type={req.type}
placeholder={req.placeholder}
bind:required={req.required}
/>
</div>
{/each}
</div>
<div class="mt-2 flex flex-row justify-between">
<div class="flex flex-row gap-x-2">
<label class="inline-flex cursor-pointer items-center">
<input
type="checkbox"
class="peer sr-only"
checked={selectedAnalytics.isEnabled}
on:change={() => {
selectedAnalytics.isEnabled = !selectedAnalytics.isEnabled;
}}
/>
<div
class="peer relative h-6 w-11 rounded-full bg-gray-200 after:absolute after:start-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800 rtl:peer-checked:after:-translate-x-full"
></div>
</label>
<div class="pt-2.5 text-sm font-medium text-muted-foreground">
{selectedAnalytics.label} is
<i class="underline">{!selectedAnalytics.isEnabled ? "disabled" : "enabled"}</i> for your site
</div>
</div>
<div>
<Button type="submit" disabled={formStateAnalytics === "loading"}>
Save Changes for {selectedAnalytics.label}
{#if formStateAnalytics === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</div>
{#if !!seoError}
<div class="py-2 text-sm font-medium text-destructive">{seoError}</div>
{/if}
{/if}
</form>
</div>
</Card.Content>
</Card.Root>
<Card.Root class="mt-4">
<Card.Header class="border-b">
<Card.Title>Search Engine Optimization</Card.Title>
<Card.Description>
Add your meta tags here. You can add multiple meta tags.
</Card.Description>
</Card.Header>
<Card.Content>
<form class="mx-auto mt-4 space-y-4" use:autoAnimate on:submit|preventDefault={formSubmit}>
{#each metaTags as metaTag, i}
<div class="flex w-full flex-row justify-between gap-2">
<div class="grid grid-cols-12 gap-x-2">
<div class="col-span-4">
<Label for="key">
Meta Name
<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"
>
&#x3C;meta name=&#x22;{metaTag.key}&#x22; content=&#x22;{metaTag.value}&#x22;&#x3E;
</div>
</Tooltip.Content>
</Tooltip.Root>
</Label>
<Input
bind:value={metaTag.key}
class="mt-2"
type="text"
id="key"
placeholder="eg. kener"
/>
</div>
<div class="col-span-7">
<Label for="value">Content</Label>
<Input
bind:value={metaTag.value}
class="mt-2"
type="text"
id="value"
placeholder="eg. kener"
/>
</div>
<div class="col-span-1">
<Button variant="ghost" class="mt-8" on:click={() => removeRow(i)}>
<X class="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/each}
<div class="relative">
<hr class="border-1 border-border-input relative top-4 h-px border-dashed" />
<Card.Header class="border-b">
<Card.Title>Search Engine Optimization</Card.Title>
<Card.Description>Add your meta tags here. You can add multiple meta tags.</Card.Description>
</Card.Header>
<Card.Content>
<form class="mx-auto mt-4 space-y-4" use:autoAnimate on:submit|preventDefault={formSubmit}>
{#each metaTags as metaTag, i}
<div class="flex w-full flex-row justify-between gap-2">
<div class="grid grid-cols-12 gap-x-2">
<div class="col-span-4">
<Label for="key">
Meta Name
<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"
>
&#x3C;meta name=&#x22;{metaTag.key}&#x22; content=&#x22;{metaTag.value}&#x22;&#x3E;
</div>
</Tooltip.Content>
</Tooltip.Root>
</Label>
<Input bind:value={metaTag.key} class="mt-2" type="text" id="key" placeholder="eg. kener" />
</div>
<div class="col-span-7">
<Label for="value">Content</Label>
<Input bind:value={metaTag.value} class="mt-2" type="text" id="value" placeholder="eg. kener" />
</div>
<div class="col-span-1">
<Button variant="ghost" class="mt-8" on:click={() => removeRow(i)}>
<X class="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/each}
<div class="relative">
<hr class="border-1 border-border-input relative top-4 h-px border-dashed" />
<Button
on:click={addNewRow}
variant="secondary"
class="absolute left-1/2 h-8 w-8 -translate-x-1/2 p-0 "
>
<Plus class="h-4 w-4 " />
</Button>
</div>
<div class="flex w-full justify-end" style="margin-top:32px">
<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>
<Button on:click={addNewRow} variant="secondary" class="absolute left-1/2 h-8 w-8 -translate-x-1/2 p-0 ">
<Plus class="h-4 w-4 " />
</Button>
</div>
<div class="flex w-full justify-end gap-x-2" style="margin-top:32px">
{#if !!metaError}
<div class="py-2 text-sm font-medium text-destructive">{metaError}</div>
{/if}
<Button type="submit" disabled={formState === "loading"}>
Save Meta Tags
{#if formState === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</form>
</Card.Content>
</Card.Root>
+6 -6
View File
@@ -6,7 +6,9 @@
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 Loader from "lucide-svelte/icons/loader";
import Info from "lucide-svelte/icons/info";
import X from "lucide-svelte/icons/x";
import * as Alert from "$lib/components/ui/alert";
import { Tooltip } from "bits-ui";
import * as Select from "$lib/components/ui/select";
@@ -431,11 +433,9 @@
{/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>
<div class="flex w-full justify-end gap-x-2">
<p class="py-2 text-sm font-medium text-destructive">
{formErrorMessage}
</p>
<Button type="submit" disabled={formState === "loading"}>
Save
+114 -41
View File
@@ -3,18 +3,26 @@
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 Plus from "lucide-svelte/icons/plus";
import X from "lucide-svelte/icons/x";
import Info from "lucide-svelte/icons/info";
import Loader from "lucide-svelte/icons/loader";
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 CodeMirror from "svelte-codemirror-editor";
import { css } from "@codemirror/lang-css";
import { githubLight, githubDark } from "@uiw/codemirror-theme-github";
import { mode } from "mode-watcher";
export let data;
let formState = "idle";
let themeError = "";
let themeData = {
pattern: "none",
@@ -23,6 +31,7 @@
barStyle: "PARTIAL",
barRoundness: "ROUNDED",
summaryStyle: "CURRENT",
kenerTheme: "default",
colorsJ: {
UP: "#4ead94",
DOWN: "#ca3038",
@@ -49,6 +58,7 @@
async function formSubmit() {
formState = "loading";
themeError = "";
let themeDataAPI = { ...themeData };
themeDataAPI.colors = JSON.stringify(themeDataAPI.colorsJ);
themeDataAPI.font = JSON.stringify(themeDataAPI.fontJ);
@@ -59,7 +69,7 @@
let data = await resp.json();
formState = "idle";
if (data.error) {
alert(data.error);
themeError = data.error;
return;
}
}
@@ -91,16 +101,64 @@
</p>
<div class="flex">
<RadioGroup.Root bind:value={themeData.pattern} onValueChange={(e) => (themeData.pattern = e)}>
<div class="flex items-center space-x-2">
<RadioGroup.Root
class="flex flex-1 flex-wrap justify-start gap-4"
bind:value={themeData.pattern}
onValueChange={(e) => (themeData.pattern = e)}
>
<div class="flex w-1/6 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">
<div class="flex w-1/6 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">
<div class="flex w-1/6 items-center space-x-2">
<RadioGroup.Item value="tiles" id="tiles" />
<Label for="tiles">Tiles</Label>
</div>
<div class="flex w-1/6 items-center space-x-2">
<RadioGroup.Item value="radial-blue" id="radial-blue" />
<Label for="radial-blue">Radial Blue</Label>
</div>
<div class="flex w-1/6 items-center space-x-2">
<RadioGroup.Item value="radial-mono" id="radial-mono" />
<Label for="radial-mono">Radial Mono</Label>
</div>
<div class="flex w-1/6 items-center space-x-2">
<RadioGroup.Item value="radial-midnight" id="radial-midnight" />
<Label for="radial-midnight">Radial Midnight</Label>
</div>
<div class="flex w-1/6 items-center space-x-2">
<RadioGroup.Item value="circle-mono" id="circle-mono" />
<Label for="circle-mono">Circle Mono</Label>
</div>
<div class="flex w-1/6 items-center space-x-2">
<RadioGroup.Item value="carbon-fibre" id="carbon-fibre" />
<Label for="carbon-fibre">Carbon Fibre</Label>
</div>
<div class="flex w-1/6 items-center space-x-2">
<RadioGroup.Item value="texture-sky" id="texture-sky" />
<Label for="texture-sky">Texture Sky</Label>
</div>
<div class="flex w-1/6 items-center space-x-2">
<RadioGroup.Item value="angular-mono" id="angular-mono" />
<Label for="angular-mono">Angular Mono</Label>
</div>
<div class="flex w-1/6 items-center space-x-2">
<RadioGroup.Item value="angular-spring" id="angular-spring" />
<Label for="angular-spring">Angular Spring</Label>
</div>
<div class="flex w-1/6 items-center space-x-2">
<RadioGroup.Item value="angular-bloom" id="angular-bloom" />
<Label for="angular-bloom">Angular Bloom</Label>
</div>
<div class="flex w-1/6 items-center space-x-2">
<RadioGroup.Item value="pets" id="pets" />
<Label for="pets">Pets</Label>
</div>
<div class="flex w-1/6 items-center space-x-2">
<RadioGroup.Item value="none" id="rnone" />
<Label for="rnone">None</Label>
</div>
@@ -235,53 +293,51 @@
</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">
<div>
<p class="font-medium">Status Colors</p>
<p class=" text-xs font-semibold text-muted-foreground">
Choose the colors for the monitor. You can choose between UP, DEGRADED, and DOWN.
</p>
</div>
<div class="flex w-full flex-row justify-start gap-x-8">
<div class="relative">
<ColorPicker
bind:hex={themeData.colorsJ.UP}
position="responsive"
isAlpha={false}
isDark={$mode == "dark"}
--input-size="16px"
isTextInput={true}
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">
<div class="relative">
<ColorPicker
bind:hex={themeData.colorsJ.DEGRADED}
position="responsive"
isAlpha={false}
isDark={$mode == "dark"}
--input-size="16px"
isTextInput={true}
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">
<div class="relative">
<ColorPicker
bind:hex={themeData.colorsJ.DOWN}
position="responsive"
isAlpha={false}
isDark={$mode == "dark"}
--input-size="16px"
isTextInput={true}
label="DOWN"
--cp-input-color="#777"
--cp-button-hover-color="#767676"
on:input={(event) => {
themeData.colorsJ.DOWN = event.detail.hex;
}}
@@ -334,20 +390,37 @@
<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.
You can add custom CSS to style your site. Do not include style tags. Go to <a
href="https://kener.ing/docs/custom-js-css-guide#grid-layout"
class="text-card-foreground"
target="_blank">this link</a
> to learn more about it.
</Label>
<textarea
bind:value={themeData.customCSS}
id="customCSS"
class="mt-1 h-48 w-full rounded-sm border p-2"
placeholder=".className&#123;color: red;&#125;"
></textarea>
<div class="overflow-hidden rounded-md">
<CodeMirror
bind:value={themeData.customCSS}
lang={css()}
theme={$mode == "dark" ? githubDark : githubLight}
styles={{
"&": {
width: "100%",
maxWidth: "100%",
height: "320px",
border: "1px solid hsl(var(--border) / var(--tw-border-opacity))",
borderRadius: "0.375rem"
}
}}
/>
</div>
</div>
</div>
<hr />
<div class="flex w-full justify-end">
<div class="flex w-full justify-end gap-x-2">
{#if !!themeError}
<div class="py-2 text-sm font-medium text-destructive">{themeError}</div>
{/if}
<Button type="submit" disabled={formState === "loading"}>
Save
Save Theme Settings
{#if formState === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
+163 -23
View File
@@ -1,6 +1,12 @@
<script>
import { Button } from "$lib/components/ui/button";
import { Plus, X, Loader, Settings, Check, ChevronRight } from "lucide-svelte";
import { page } from "$app/stores";
import Plus from "lucide-svelte/icons/plus";
import X from "lucide-svelte/icons/x";
import Loader from "lucide-svelte/icons/loader";
import Settings from "lucide-svelte/icons/settings";
import Check from "lucide-svelte/icons/check";
import ChevronRight from "lucide-svelte/icons/chevron-right";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { base } from "$app/paths";
@@ -9,8 +15,14 @@
import { createEventDispatcher } from "svelte";
import { onMount } from "svelte";
import { IsValidURL } from "$lib/clientTools.js";
import { DiscordJSONTemplate, WebhookJSONTemplate, SlackJSONTemplate } from "$lib/anywhere.js";
import { clickOutsideAction, slide } from "svelte-legos";
import * as Card from "$lib/components/ui/card";
import CodeMirror from "svelte-codemirror-editor";
import { json } from "@codemirror/lang-json";
import { githubLight, githubDark } from "@uiw/codemirror-theme-github";
import { mode } from "mode-watcher";
let status = "ACTIVE";
let formState = "idle";
let showAddTrigger = false;
@@ -29,8 +41,12 @@
headers: [],
to: "",
from: data.fromEmail,
webhook_body: "",
webhook_body: WebhookJSONTemplate,
has_webhook_body: false,
has_discord_body: false,
discord_body: DiscordJSONTemplate,
has_slack_body: false,
slack_body: SlackJSONTemplate,
email_type: data.preferredModeEmail,
smtp_host: data.smtp?.smtp_host ? data.smtp.smtp_host : "",
smtp_port: data.smtp?.smtp_port ? data.smtp.smtp_port : "",
@@ -52,8 +68,12 @@
headers: [],
to: "",
from: data.fromEmail,
webhook_body: "",
webhook_body: WebhookJSONTemplate,
has_webhook_body: false,
has_discord_body: false,
discord_body: DiscordJSONTemplate,
has_slack_body: false,
slack_body: SlackJSONTemplate,
email_type: data.preferredModeEmail,
smtp_host: data.smtp?.smtp_host ? data.smtp.smtp_host : "",
smtp_port: data.smtp?.smtp_port ? data.smtp.smtp_port : "",
@@ -91,13 +111,35 @@
async function addNewTrigger() {
invalidFormMessage = "";
formState = "loading";
// formState = "loading";
//newTrigger.trigger_type present not empty
if (newTrigger.trigger_type == "") {
invalidFormMessage = "Trigger Type is required";
formState = "idle";
return;
}
if (newTrigger.trigger_type == "discord") {
if (newTrigger.trigger_meta.has_discord_body) {
try {
JSON.parse(newTrigger.trigger_meta.discord_body);
} catch (e) {
invalidFormMessage = "Invalid JSON in Discord Body";
formState = "idle";
return;
}
}
}
if (newTrigger.trigger_type == "slack") {
if (newTrigger.trigger_meta.has_slack_body) {
try {
JSON.parse(newTrigger.trigger_meta.slack_body);
} catch (e) {
invalidFormMessage = "Invalid JSON in Slack Body";
formState = "idle";
return;
}
}
}
if (newTrigger.trigger_type == "email") {
newTrigger.trigger_meta.url = "";
@@ -346,10 +388,12 @@
<Loader class="ml-2 mt-2 inline h-6 w-6 animate-spin" />
{/if}
</div>
<Button on:click={addNewTriggerFn}>
<Plus class="mr-2 inline h-6 w-6" />
New Trigger
</Button>
{#if $page.data.user.role != "member"}
<Button on:click={addNewTriggerFn}>
<Plus class="mr-2 inline h-6 w-6" />
New Trigger
</Button>
{/if}
</div>
<div class="mt-4">
@@ -382,12 +426,13 @@
{#if trigger.testLoaders == "success"}
<Check class="mr-1 inline h-3 w-3 text-green-500 " />
{/if}
<span class="h-4 text-xs font-medium"> TEST </span>
</Button>
<Button variant="secondary" class=" h-8 w-8 p-2" href={"#" + trigger.id}>
<Settings class="inline h-4 w-4" />
<span class="h-4 text-xs font-medium"> Test Trigger </span>
</Button>
{#if $page.data.user.role != "member"}
<Button variant="secondary" class=" h-8 w-8 p-2" href={"#" + trigger.id}>
<Settings class="inline h-4 w-4" />
</Button>
{/if}
</div>
</Card.Header>
</Card.Root>
@@ -551,7 +596,7 @@
</Button>
</div>
<div>
<label>
<label class="text-sm font-medium">
<input
on:change={(e) => {
newTrigger.trigger_meta.has_webhook_body = e.target.checked;
@@ -567,21 +612,116 @@
set. The default is JSON. There are <a
target="_blank"
class="text-blue-500"
href="https://kener.ing/docs/triggers#webhook-body">variables</a
href="https://kener.ing/docs/triggers#triggers-trigger-variables">mustache variables</a
>
that you can use for the webhook body. To use a variable wrap it like
<code>$&#123;variable&#125;</code>
<code>&#123;&#123;variable&#125;&#125;</code>. There are some examples
<a target="_blank" class="text-blue-500" href="https://kener.ing/docs/triggers#webhook-examples"
>here</a
>
</p>
<div class="w-full">
<textarea
<div class="overflow-hidden rounded-md">
<CodeMirror
bind:value={newTrigger.trigger_meta.webhook_body}
class="mt-2 h-[500px] w-full rounded-sm border p-2"
placeholder={placeholderWebhookBody}
></textarea>
lang={json()}
theme={$mode == "dark" ? githubDark : githubLight}
styles={{
"&": {
width: "100%",
maxWidth: "100%",
height: "320px",
border: "1px solid hsl(var(--border) / var(--tw-border-opacity))",
borderRadius: "0.375rem"
}
}}
/>
</div>
{/if}
</div>
</div>
{:else if newTrigger.trigger_type == "slack"}
<div class="mt-4 flex w-full flex-col gap-y-2">
<div>
<label class="text-sm font-medium">
<input
on:change={(e) => {
newTrigger.trigger_meta.has_slack_body = e.target.checked;
}}
type="checkbox"
checked={newTrigger.trigger_meta.has_slack_body}
/>
Use Custom Slack Payload
</label>
</div>
{#if !!newTrigger.trigger_meta.has_slack_body}
<p class="my-2 text-xs text-muted-foreground">
You can use a custom slack body. You can use mustache variable to generate a body. See the <a
target="_blank"
class="text-blue-500"
href="https://kener.ing/docs/triggers#triggers-trigger-variables">variables</a
>
that you can use for the slack body.
</p>
<div class="overflow-hidden rounded-md">
<CodeMirror
bind:value={newTrigger.trigger_meta.slack_body}
lang={json()}
theme={$mode == "dark" ? githubDark : githubLight}
styles={{
"&": {
width: "100%",
maxWidth: "100%",
height: "320px",
border: "1px solid hsl(var(--border) / var(--tw-border-opacity))",
borderRadius: "0.375rem"
}
}}
/>
</div>
{/if}
</div>
{:else if newTrigger.trigger_type == "discord"}
<div class="mt-4 flex w-full flex-col gap-y-2">
<div>
<label class="text-sm font-medium">
<input
on:change={(e) => {
newTrigger.trigger_meta.has_discord_body = e.target.checked;
}}
type="checkbox"
checked={newTrigger.trigger_meta.has_discord_body}
/>
Use Custom Discord Payload
</label>
</div>
{#if !!newTrigger.trigger_meta.has_discord_body}
<p class="my-2 text-xs text-muted-foreground">
You can use a custom discord body. You can use mustache variable to generate a body. See the <a
target="_blank"
class="text-blue-500"
href="https://kener.ing/docs/triggers#triggers-trigger-variables">variables</a
>
that you can use for the discord body.
</p>
<div class="overflow-hidden rounded-md">
<CodeMirror
bind:value={newTrigger.trigger_meta.discord_body}
lang={json()}
theme={$mode == "dark" ? githubDark : githubLight}
styles={{
"&": {
width: "100%",
maxWidth: "100%",
height: "320px",
border: "1px solid hsl(var(--border) / var(--tw-border-opacity))",
borderRadius: "0.375rem"
}
}}
/>
</div>
{/if}
</div>
{:else if newTrigger.trigger_type == "email"}
<div class="mt-4 w-full">
<RadioGroup.Root class="my-4 flex" bind:value={newTrigger.trigger_meta.email_type}>
@@ -675,11 +815,11 @@
</div>
<div class="absolute bottom-0 grid h-16 w-full grid-cols-6 justify-end gap-2 border-t p-3 pr-6">
<div class="col-span-5 py-2.5">
<p class="text-right text-xs font-medium text-red-500">{invalidFormMessage}</p>
<p class="text-right text-sm font-medium text-destructive">{invalidFormMessage}</p>
</div>
<div class="col-span-1">
<Button class="col-span-1 w-full" disabled={formState === "loading"} on:click={addNewTrigger}>
Save
Save Trigger
{#if formState === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
+34 -14
View File
@@ -8,8 +8,14 @@
import { sub, startOfDay, getUnixTime } from "date-fns";
import GMI from "$lib/components/gmi.svelte";
import { page } from "$app/stores";
import { Share2, ArrowRight, Settings, TrendingUp, Loader, ChevronLeft, ChevronRight } from "lucide-svelte";
import { analyticsEvent } from "$lib/boringOne";
import Share2 from "lucide-svelte/icons/share-2";
import ArrowRight from "lucide-svelte/icons/arrow-right";
import Settings from "lucide-svelte/icons/settings";
import TrendingUp from "lucide-svelte/icons/trending-up";
import Loader from "lucide-svelte/icons/loader";
import ChevronLeft from "lucide-svelte/icons/chevron-left";
import ChevronRight from "lucide-svelte/icons/chevron-right";
import { buttonVariants } from "$lib/components/ui/button";
import { createEventDispatcher } from "svelte";
import { afterUpdate } from "svelte";
@@ -124,6 +130,10 @@
let rollerLoading = false;
async function rollSummary(r) {
let newRolledAt = r;
analyticsEvent("monitor_interval_switch", {
tag: monitor.tag,
interval: uptimesRollers[newRolledAt].text
});
if (uptimesRollers[newRolledAt].value === undefined) {
rollerLoading = true;
@@ -173,12 +183,18 @@
if (embed) {
return;
}
let incidentIDs = incidentObj?.ids || [];
dayUptime = "NA";
dateFetchedFor = f(new Date(bar.timestamp * 1000), "EEEE, MMMM do, yyyy", selectedLang, $page.data.localTz);
showDailyDataModal = true;
loadingDayData = true;
dayIncidentsFull = [];
analyticsEvent("monitor_day_data", {
tag: monitor.tag,
data_date: dateFetchedFor
});
setTimeout(() => {
getToday(bar.timestamp, incidentIDs);
}, 50);
@@ -207,7 +223,7 @@
{@html monitor.description}
{/if}
</p>
<div class="absolute right-14 top-5 flex gap-x-2">
<div class="absolute right-4 top-5 flex gap-x-2 md:right-14">
{#if $page.data.isLoggedIn}
<Button
href="{base}/manage/app/monitors#{monitor.tag}"
@@ -229,16 +245,6 @@
>
<Share2 class="h-4 w-4 " />
</Button>
{#if monitor.monitor_type === "GROUP"}
<Button
class="bounce-right h-5 p-0 text-muted-foreground hover:text-primary"
variant="link"
href="{base}?group={monitor.tag}"
rel="external"
>
<ArrowRight class="arrow h-4 w-4" />
</Button>
{/if}
</div>
</div>
</div>
@@ -319,7 +325,6 @@
></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"
@@ -337,6 +342,21 @@
{/if}
{/each}
</div>
{#if monitor.monitor_type === "GROUP" && !!!embed}
<div class="-mt-4 flex justify-end">
<Button
variant="secondary"
href="{base}?group={monitor.tag}"
rel="external"
class="bounce-right h-8 text-xs"
on:click={scrollToRight}
>
{l(lang, "View in detail")}
<ArrowRight class="arrow ml-1.5 h-4 w-4" />
</Button>
</div>
{/if}
{#if showDailyDataModal}
<div
transition:slide={{ direction: "bottom" }}
+59 -4
View File
@@ -1,17 +1,69 @@
<script>
import { Button } from "$lib/components/ui/button";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { Languages, Menu } from "lucide-svelte";
import Languages from "lucide-svelte/icons/languages";
import Menu from "lucide-svelte/icons/menu";
import { base } from "$app/paths";
import { analyticsEvent } from "$lib/boringOne";
import GMI from "$lib/components/gmi.svelte";
export let data;
let defaultPattern = data.site?.pattern || "squares";
let allPets = [
{
url: base + "/chicken.gif",
bottom: "-5"
},
{
url: base + "/dog.gif",
bottom: "-17"
},
{
url: base + "/cockatiel.gif",
bottom: "-10"
},
{
url: base + "/crab.gif",
bottom: "-20"
},
{
url: base + "/fox.gif",
bottom: "-9"
},
{
url: base + "/horse.gif",
bottom: "-11"
},
{
url: base + "/panda.gif",
bottom: "0"
},
{
url: base + "/totoro.gif",
bottom: "-27"
},
{
url: base + "/rabbit.gif",
bottom: "0"
},
{
url: base + "/duck.gif",
bottom: "-5"
},
{
url: base + "/snake.gif",
bottom: "0"
}
];
let randomPet = allPets[Math.floor(Math.random() * allPets.length)];
</script>
<div class="{defaultPattern}-pattern"></div>
{#if defaultPattern == "pets" && !!randomPet}
<div class="pets-pattern" style="background-image: url({randomPet.url});bottom: {randomPet.bottom}px"></div>
{:else}
<div class="{defaultPattern}-pattern"></div>
{/if}
<header class="blurry-bg sticky top-0 z-50 mx-auto md:mt-2">
<header class="sticky top-0 z-50 mx-auto md:mt-2">
<div class="container flex h-14 max-w-[820px] items-center border bg-card px-3 md:rounded-md">
<a rel="external" href={data.site.home ? data.site.home : base} class="mr-6 flex items-center space-x-2">
{#if data.site.logo}
@@ -31,7 +83,10 @@
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)}
on:click={() =>
analyticsEvent("navigation", {
name: navItem.name
})}
>
{#if navItem.iconURL}
<GMI src={navItem.iconURL} classList="mr-1.5 mt-0.5 inline h-4" alt={navItem.name} />
+8 -1
View File
@@ -1,5 +1,12 @@
<script>
import { Share2, Link, CopyCheck, Code, TrendingUp, Percent, Loader, ExternalLink } from "lucide-svelte";
import Share2 from "lucide-svelte/icons/share-2";
import Link from "lucide-svelte/icons/link";
import CopyCheck from "lucide-svelte/icons/copy-check";
import Code from "lucide-svelte/icons/code";
import TrendingUp from "lucide-svelte/icons/trending-up";
import Percent from "lucide-svelte/icons/percent";
import Loader from "lucide-svelte/icons/loader";
import ExternalLink from "lucide-svelte/icons/external-link";
import { Button } from "$lib/components/ui/button";
import { base } from "$app/paths";
import { onMount } from "svelte";
@@ -1,26 +1,26 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import { ChevronDown } from "lucide-svelte";
import { cn } from "$lib/utils";
import { Accordion as AccordionPrimitive } from "bits-ui";
import ChevronDown from "lucide-svelte/icons/chevron-down";
import { cn } from "$lib/utils";
type $$Props = AccordionPrimitive.TriggerProps;
type $$Events = AccordionPrimitive.TriggerEvents;
type $$Props = AccordionPrimitive.TriggerProps;
type $$Events = AccordionPrimitive.TriggerEvents;
let className: $$Props["class"] = undefined;
export let level: AccordionPrimitive.HeaderProps["level"] = 3;
export { className as class };
let className: $$Props["class"] = undefined;
export let level: AccordionPrimitive.HeaderProps["level"] = 3;
export { className as class };
</script>
<AccordionPrimitive.Header {level} class="flex">
<AccordionPrimitive.Trigger
class={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...$$restProps}
on:click
>
<slot />
<ChevronDown class="h-4 w-4 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
<AccordionPrimitive.Trigger
class={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...$$restProps}
on:click
>
<slot />
<ChevronDown class="h-4 w-4 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
@@ -1,35 +1,35 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils";
import { Check } from "lucide-svelte";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils";
import Check from "lucide-svelte/icons/check";
type $$Props = DropdownMenuPrimitive.CheckboxItemProps;
type $$Events = DropdownMenuPrimitive.CheckboxItemEvents;
type $$Props = DropdownMenuPrimitive.CheckboxItemProps;
type $$Events = DropdownMenuPrimitive.CheckboxItemEvents;
let className: $$Props["class"] = undefined;
export let checked: $$Props["checked"] = undefined;
export { className as class };
let className: $$Props["class"] = undefined;
export let checked: $$Props["checked"] = undefined;
export { className as class };
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:checked
class={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50",
className
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerdown
on:pointerleave
on:pointermove
bind:checked
class={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50",
className
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerdown
on:pointerleave
on:pointermove
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.CheckboxIndicator>
<Check class="h-4 w-4" />
</DropdownMenuPrimitive.CheckboxIndicator>
</span>
<slot />
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.CheckboxIndicator>
<Check class="h-4 w-4" />
</DropdownMenuPrimitive.CheckboxIndicator>
</span>
<slot />
</DropdownMenuPrimitive.CheckboxItem>
@@ -1,35 +1,35 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils";
import { Circle } from "lucide-svelte";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils";
import Circle from "lucide-svelte/icons/circle";
type $$Props = DropdownMenuPrimitive.RadioItemProps;
type $$Events = DropdownMenuPrimitive.RadioItemEvents;
type $$Props = DropdownMenuPrimitive.RadioItemProps;
type $$Events = DropdownMenuPrimitive.RadioItemEvents;
let className: $$Props["class"] = undefined;
export let value: $$Props["value"];
export { className as class };
let className: $$Props["class"] = undefined;
export let value: $$Props["value"];
export { className as class };
</script>
<DropdownMenuPrimitive.RadioItem
class={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50",
className
)}
{value}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerdown
on:pointerleave
on:pointermove
class={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50",
className
)}
{value}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerdown
on:pointerleave
on:pointermove
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.RadioIndicator>
<Circle class="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.RadioIndicator>
</span>
<slot />
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.RadioIndicator>
<Circle class="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.RadioIndicator>
</span>
<slot />
</DropdownMenuPrimitive.RadioItem>
@@ -1,32 +1,31 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils";
import { ChevronRight } from "lucide-svelte";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils";
import ChevronRight from "lucide-svelte/icons/chevron-right";
type $$Props = DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
};
type $$Events = DropdownMenuPrimitive.SubTriggerEvents;
type $$Props = DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
};
type $$Events = DropdownMenuPrimitive.SubTriggerEvents;
let className: $$Props["class"] = undefined;
export let inset: $$Props["inset"] = undefined;
export { className as class };
let className: $$Props["class"] = undefined;
export let inset: $$Props["inset"] = undefined;
export { className as class };
</script>
<DropdownMenuPrimitive.SubTrigger
class={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[state=open]:bg-accent data-[highlighted]:text-accent-foreground data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerleave
on:pointermove
class={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[state=open]:bg-accent data-[highlighted]:text-accent-foreground data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerleave
on:pointermove
>
<slot />
<ChevronRight class="ml-auto h-4 w-4" />
<slot />
<ChevronRight class="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
@@ -1,28 +1,28 @@
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
import { Circle } from "lucide-svelte";
import { cn } from "$lib/utils";
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
import Circle from "lucide-svelte/icons/circle";
import { cn } from "$lib/utils";
type $$Props = RadioGroupPrimitive.ItemProps;
type $$Events = RadioGroupPrimitive.ItemEvents;
type $$Props = RadioGroupPrimitive.ItemProps;
type $$Events = RadioGroupPrimitive.ItemEvents;
let className: $$Props["class"] = undefined;
export let value: $$Props["value"];
export { className as class };
let className: $$Props["class"] = undefined;
export let value: $$Props["value"];
export { className as class };
</script>
<RadioGroupPrimitive.Item
{value}
class={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...$$restProps}
on:click
{value}
class={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...$$restProps}
on:click
>
<div class="flex items-center justify-center">
<RadioGroupPrimitive.ItemIndicator>
<Circle class="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.ItemIndicator>
</div>
<div class="flex items-center justify-center">
<RadioGroupPrimitive.ItemIndicator>
<Circle class="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.ItemIndicator>
</div>
</RadioGroupPrimitive.Item>
+30 -30
View File
@@ -1,38 +1,38 @@
<script lang="ts">
import { cn } from "$lib/utils";
import { Select as SelectPrimitive } from "bits-ui";
import { Check } from "lucide-svelte";
import { cn } from "$lib/utils";
import { Select as SelectPrimitive } from "bits-ui";
import Check from "lucide-svelte/icons/check";
type $$Props = SelectPrimitive.ItemProps;
type $$Events = SelectPrimitive.ItemEvents;
type $$Props = SelectPrimitive.ItemProps;
type $$Events = SelectPrimitive.ItemEvents;
let className: $$Props["class"] = undefined;
export let value: $$Props["value"];
export let label: $$Props["label"] = undefined;
export let disabled: $$Props["disabled"] = undefined;
export { className as class };
let className: $$Props["class"] = undefined;
export let value: $$Props["value"];
export let label: $$Props["label"] = undefined;
export let disabled: $$Props["disabled"] = undefined;
export { className as class };
</script>
<SelectPrimitive.Item
{value}
{disabled}
{label}
class={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50",
className
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerleave
on:pointermove
{value}
{disabled}
{label}
class={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50",
className
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerleave
on:pointermove
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check class="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<slot />
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check class="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<slot />
</SelectPrimitive.Item>
@@ -1,27 +1,27 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { ChevronDown } from "lucide-svelte";
import { cn } from "$lib/utils";
import { Select as SelectPrimitive } from "bits-ui";
import ChevronDown from "lucide-svelte/icons/chevron-down";
import { cn } from "$lib/utils";
type $$Props = SelectPrimitive.TriggerProps;
type $$Events = SelectPrimitive.TriggerEvents;
type $$Props = SelectPrimitive.TriggerProps;
type $$Events = SelectPrimitive.TriggerEvents;
let className: $$Props["class"] = undefined;
export { className as class };
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<SelectPrimitive.Trigger
class={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...$$restProps}
let:builder
on:click
on:keydown
class={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...$$restProps}
let:builder
on:click
on:keydown
>
<slot {builder} />
<div>
<ChevronDown class="h-4 w-4 opacity-50" />
</div>
<slot {builder} />
<div>
<ChevronDown class="h-4 w-4 opacity-50" />
</div>
</SelectPrimitive.Trigger>
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "VERFÜGBAR",
"Upcoming Maintenance": "Bevorstehende Wartung",
"Updates": "Updates",
"Uptime": "Verfügbarkeit"
"Uptime": "Verfügbarkeit",
"View in detail": "Details anzeigen"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "OPPE",
"Upcoming Maintenance": "Kommende vedligeholdelse",
"Updates": "Opdateringer",
"Uptime": "Oppetid"
"Uptime": "Oppetid",
"View in detail": "Vis i detaljer"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "UP",
"Upcoming Maintenance": "Upcoming Maintenance",
"Updates": "Updates",
"Uptime": "Uptime"
"Uptime": "Uptime",
"View in detail": "View in detail"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "En ligne",
"Upcoming Maintenance": "Maintenance à venir",
"Updates": "Mises à jour",
"Uptime": "Temps de fonctionnement"
"Uptime": "Temps de fonctionnement",
"View in detail": "Voir en détail"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "अप",
"Upcoming Maintenance": "आगामी मेंटेनेंस",
"Updates": "अपडेट्स",
"Uptime": "अपटाइम"
"Uptime": "अपटाइम",
"View in detail": "विस्तार में देखें"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "稼働中",
"Upcoming Maintenance": "今後のメンテナンス",
"Updates": "更新",
"Uptime": "稼働時間"
"Uptime": "稼働時間",
"View in detail": "詳細を表示"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "작동 중",
"Upcoming Maintenance": "예정된 유지보수",
"Updates": "업데이트",
"Uptime": "업타임"
"Uptime": "업타임",
"View in detail": "자세히 보기"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "OPPE",
"Upcoming Maintenance": "Kommende vedlikehold",
"Updates": "Oppdateringer",
"Uptime": "Oppetid"
"Uptime": "Oppetid",
"View in detail": "Vis detaljer"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "BEREIKBAAR",
"Upcoming Maintenance": "Aankomend onderhoud",
"Updates": "Updates",
"Uptime": "Uptime"
"Uptime": "Uptime",
"View in detail": "Bekijk in detail"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "OPERACIONAL",
"Upcoming Maintenance": "Manutenção Agendada",
"Updates": "Atualizações",
"Uptime": "Tempo de Atividade"
"Uptime": "Tempo de Atividade",
"View in detail": "Ver em detalhes"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "РАБОТАЕТ",
"Upcoming Maintenance": "Предстоящее техническое обслуживание",
"Updates": "Обновления",
"Uptime": "Время работы"
"Uptime": "Время работы",
"View in detail": "Подробный просмотр"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "ÇALIŞIYOR",
"Upcoming Maintenance": "Yaklaşan Bakım",
"Updates": "Güncellemeler",
"Uptime": "Çalışma Süresi"
"Uptime": "Çalışma Süresi",
"View in detail": "Detaylı Görüntüle"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"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"
"Uptime": "Thời gian hoạt động",
"View in detail": "Xem chi tiết"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "可用",
"Upcoming Maintenance": "即将进行的维护",
"Updates": "更新",
"Uptime": "正常运行时间"
"Uptime": "正常运行时间",
"View in detail": "详细查看"
}
+2
View File
@@ -104,6 +104,7 @@ const MANUAL = "manual";
const WEBHOOK = "webhook";
const DEFAULT_STATUS = "default_status";
const SIGNAL = "signal";
const INVITE_VERIFY_EMAIL = "invite_verify_email";
export {
MONITOR,
@@ -123,4 +124,5 @@ export {
WEBHOOK,
DEFAULT_STATUS,
SIGNAL,
INVITE_VERIFY_EMAIL,
};
+364 -10
View File
@@ -15,10 +15,13 @@ import {
import db from "../db/db.js";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { Resend } from "resend";
import crypto from "crypto";
import { format, subMonths, addMonths, startOfMonth } from "date-fns";
import { UP, DOWN, DEGRADED, NO_DATA, REALTIME, SIGNAL } from "../constants.js";
import { GetMinuteStartNowTimestampUTC, GetNowTimestampUTC } from "../tool.js";
import { GetMinuteStartNowTimestampUTC, GetNowTimestampUTC, ReplaceAllOccurrences, ValidateEmail } from "../tool.js";
import getSMTPTransport from "../notification/smtps.js";
const saltRounds = 10;
const DUMMY_SECRET = "DUMMY_SECRET";
@@ -74,6 +77,11 @@ const siteDataKeys = [
isValid: (value) => typeof value === "string",
data_type: "string",
},
{
key: "kenerTheme",
isValid: (value) => typeof value === "string",
data_type: "string",
},
{
key: "customCSS",
isValid: (value) => typeof value === "string",
@@ -86,8 +94,25 @@ const siteDataKeys = [
},
{
key: "pattern",
//string dots or sqaures or circ
isValid: (value) => typeof value === "string" && ["dots", "squares", "none"].includes(value),
//string dots or squares or circle
isValid: (value) =>
typeof value === "string" &&
[
"dots",
"squares",
"tiles",
"none",
"radial-blue",
"radial-mono",
"radial-midnight",
"circle-mono",
"carbon-fibre",
"texture-sky",
"angular-mono",
"angular-spring",
"angular-bloom",
"pets",
].includes(value),
data_type: "string",
},
{
@@ -166,6 +191,31 @@ const siteDataKeys = [
isValid: (value) => typeof value === "string" && value.trim().length > 0,
data_type: "string",
},
{
key: "analytics.googleTagManager",
isValid: IsValidJSONString,
data_type: "object",
},
{
key: "analytics.plausible",
isValid: IsValidJSONString,
data_type: "object",
},
{
key: "analytics.mixpanel",
isValid: IsValidJSONString,
data_type: "object",
},
{
key: "analytics.amplitude",
isValid: IsValidJSONString,
data_type: "object",
},
{
key: "analytics.clarity",
isValid: IsValidJSONString,
data_type: "object",
},
];
export function InsertKeyValue(key, value) {
@@ -200,6 +250,20 @@ export async function GetAllSiteData() {
return transformedData;
}
//get all analytics data as json
export async function GetAllAnalyticsData() {
let data = await db.getAllSiteDataAnalytics();
//return all data as key value pairs, transform using data_type
let transformedData = [];
for (const d of data) {
transformedData.push({
key: d.key,
value: JSON.parse(d.value),
});
}
return transformedData;
}
export const CreateUpdateMonitor = async (monitor) => {
let monitorData = { ...monitor };
if (monitorData.id) {
@@ -239,6 +303,16 @@ export const GetMonitorsParsed = async (query) => {
export const CreateUpdateTrigger = async (alert) => {
let alertData = { ...alert };
let alertMetaJSON = JSON.parse(alertData.trigger_meta);
if (alertData.trigger_type === "email") {
let emailsArray = alertMetaJSON.to.split(",").map((email) => email.trim());
for (let i = 0; i < emailsArray.length; i++) {
if (!ValidateEmail(emailsArray[i])) {
throw new Error(`Invalid email: ${emailsArray[i]}`);
}
}
}
if (alertData.id) {
return await db.updateTrigger(alertData);
} else {
@@ -302,6 +376,23 @@ export const VerifyPassword = async (plainTextPassword, hashedPassword) => {
export const GetLatestMonitoringData = async (monitor_tag) => {
return await db.getLatestMonitoringData(monitor_tag);
};
export const GetLatestStatusActiveAll = async (monitor_tags) => {
let latestData = await db.getLatestMonitoringDataAllActive(monitor_tags);
let status = "NO_DATA";
for (let i = 0; i < latestData.length; i++) {
//if any status is down then status = down, if any is degraded then status = degraded, down > degraded > up
if (latestData[i].status === "DOWN") {
status = "DOWN";
} else if (latestData[i].status === "DEGRADED" && status !== "DOWN") {
status = "DEGRADED";
} else if (latestData[i].status === "UP" && status !== "DOWN" && status !== "DEGRADED") {
status = "UP";
}
}
return {
status: status,
};
};
export const GetLastHeartbeat = async (monitor_tag) => {
return await db.getLastHeartbeat(monitor_tag);
};
@@ -362,7 +453,6 @@ export const VerifyToken = async (token) => {
const decoded = jwt.verify(token, process.env.KENER_SECRET_KEY || DUMMY_SECRET);
return decoded; // Returns the decoded payload if the token is valid
} catch (err) {
console.error("Error verifying token:", err);
return undefined; // Returns null if the token is invalid
}
};
@@ -397,6 +487,12 @@ export const CreateNewAPIKey = async (data) => {
const apiKey = generateApiKey();
const hashed_key = await createHash(apiKey);
//insert into db
//data.name cant be empty
if (!data.name) {
throw new Error("Name is required");
}
await db.createNewApiKey({
name: data.name,
hashed_key: hashed_key,
@@ -438,12 +534,6 @@ export const IsSetupComplete = async () => {
return data.length > 0;
};
export const HashString = (str) => {
const hash = crypto.createHash("sha256");
hash.update(str);
return hash.digest("hex");
};
export const InterpolateData = (rawData, startTimestamp, initialStatus, overrideEndTimestamp) => {
const interpolatedData = [];
let currentStatus = initialStatus || "UP";
@@ -479,6 +569,19 @@ export const GetLastStatusBefore = async (monitor_tag, timestamp) => {
}
return NO_DATA;
};
export const GetMonitoringData = async (tag, since, now) => {
return await db.getMonitoringData(tag, since, now);
};
export const GetMonitoringDataAll = async (tags, since, now) => {
return await db.getMonitoringDataAll(tags, since, now);
};
export const GetLastStatusBeforeAll = async (monitor_tags, timestamp) => {
let data = await db.getLastStatusBeforeAll(monitor_tags, timestamp);
if (data) {
return data.status;
}
return NO_DATA;
};
export const AggregateData = (rawData) => {
//data like [{ timestamp: 1732435920, status: 'NO_DATA' }]
@@ -899,6 +1002,257 @@ export const GetSMTPFromENV = () => {
};
};
export const IsResendSetup = () => {
return !!process.env.RESEND_API_KEY && !!process.env.RESEND_SENDER_EMAIL;
};
export const IsEmailSetup = () => {
return !!GetSMTPFromENV || IsResendSetup();
};
export const UpdateUserData = async (data) => {
let userID = data.userID;
let updateKey = data.updateKey;
let updateValue = data.updateValue;
//if updateKey is password, throw error
if (updateKey === "password") {
throw new Error("Password cannot be updated using this method");
}
//if updateValue is empty, throw error
if (!!!updateValue) {
throw new Error("Update value cannot be empty");
}
switch (updateKey) {
case "name":
return await db.updateUserName(userID, updateValue);
case "role":
return await db.updateUserRole(userID, updateValue);
case "is_verified":
return await db.updateIsVerified(userID, updateValue);
default:
throw new Error("Invalid update key");
}
};
export const CreateNewInvitation = async (data) => {
let invite = {};
//create a token
let token = crypto.randomBytes(32).toString("hex");
let hashedToken = createHash(token);
let invitation_token = data.invitation_type.toLowerCase() + "_" + hashedToken;
invite.invitation_token = invitation_token;
invite.invitation_type = data.invitation_type;
invite.invited_user_id = data.invited_user_id;
invite.invited_by_user_id = data.invited_by_user_id;
invite.invitation_meta = data.invitation_meta;
invite.invitation_expiry = data.invitation_expiry;
invite.invitation_status = "PENDING";
//update old invitations to VOID
await db.updateInvitationStatusToVoid(invite.invited_user_id, invite.invitation_type);
let res = await db.insertInvitation(invite);
return {
invitation_token,
};
};
//check if there is a row for given invited_user_id,invitation_type and invitation_status = PENDING
export const CheckInvitationExists = async (invited_user_id, invitation_type) => {
let invitation = await db.invitationExists(invited_user_id, invitation_type);
return !!invitation;
};
//getInvitationByToken
export const GetActiveInvitationByToken = async (invitation_token) => {
let invitation = await db.getActiveInvitationByToken(invitation_token);
return invitation;
};
//updateInvitationStatusToAccepted
export const UpdateInvitationStatusToAccepted = async (invitation_token) => {
return await db.updateInvitationStatusToAccepted(invitation_token);
};
//getUserById
export const GetUserByID = async (userID) => {
return await db.getUserById(userID);
};
//getUserByEmail
export const GetUserByEmail = async (email) => {
return await db.getUserByEmail(email);
};
export const SendEmailWithTemplate = async (template, data, email, subject, emailText) => {
//for each key in data, replace the key in template with value
for (const key in data) {
if (Object.hasOwnProperty.call(data, key)) {
const value = data[key];
template = ReplaceAllOccurrences(template, `{{${key}}}`, value);
}
}
const senderEmail = process.env.RESEND_SENDER_EMAIL;
const resendKey = process.env.RESEND_API_KEY;
let mail = {
from: senderEmail,
to: [email],
subject: subject,
text: emailText,
html: template,
};
let smtpData = GetSMTPFromENV();
try {
if (!!smtpData) {
const transporter = getSMTPTransport(smtpData);
const mailOptions = {
from: smtpData.smtp_from_email,
to: email,
subject: mail.subject,
html: mail.html,
text: mail.text,
};
return await transporter.sendMail(mailOptions);
} else {
const resend = new Resend(resendKey);
return await resend.emails.send(mail);
}
} catch (error) {
console.error("Error sending email via SMTP", error);
throw new Error("Error sending email v");
}
};
export const GetSiteLogoURL = async (siteURL, logo, base) => {
if (logo.startsWith("http")) {
return logo;
}
return siteURL + base + logo;
};
export const GetAllUsers = async () => {
return await db.getAllUsers();
};
//get all users paginated
export const GetAllUsersPaginated = async (data) => {
return await db.getUsersPaginated(data.page, data.limit);
};
//given a limit return total pages
export const GetTotalUserPages = async (limit) => {
let totalUsers = await db.getTotalUsers();
let totalPages = Math.ceil(totalUsers.count / limit);
return totalPages;
};
export const ValidatePassword = (password) => {
return /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).{8,}$/.test(password);
};
export const UpdatePassword = async (data) => {
let { userID, newPassword, newPlainPassword } = data;
if (!ValidatePassword(newPassword)) {
throw new Error(
"Password must contain at least 8 characters, one uppercase letter, one lowercase letter and one number",
);
}
// newPassword should match newPlainPassword
if (newPassword !== newPlainPassword) {
throw new Error("Passwords do not match");
}
//hash the password
let hashedPassword = await HashPassword(newPassword);
return await db.updateUserPassword({
id: userID,
password_hash: hashedPassword,
});
};
export const ManualUpdateUserData = async (byUser, forUserId, data) => {
let forUser = await db.getUserById(forUserId);
//only admins can update
if (byUser.role !== "admin") {
throw new Error("You do not have permission to update user");
}
if (data.updateType == "role") {
return await db.updateUserRole(forUser.id, data.role);
} else if (data.updateType == "is_active") {
return await db.updateUserIsActive(forUser.id, data.is_active);
} else if (data.updateType == "password") {
return await UpdatePassword({
userID: forUser.id,
newPassword: data.password,
newPlainPassword: data.passwordPlain,
});
}
};
export const CreateNewUser = async (currentUser, data) => {
let acceptedRoles = ["member", "editor"];
if (!acceptedRoles.includes(data.role)) {
throw new Error("Invalid role");
}
if (currentUser.role === "member") {
throw new Error("Only admins and editors can create new users");
}
//if data.email empty, throw error
if (!!!data.email) {
throw new Error("Email cannot be empty");
}
//if data.name empty, throw error
if (!!!data.name) {
throw new Error("Name cannot be empty");
}
//if data.password empty, throw error
if (!!!data.password) {
throw new Error("Password cannot be empty");
}
//if data.role empty, throw error
if (!!!data.role) {
throw new Error("Role cannot be empty");
}
//if data.password not equal to data.plainPassword, throw error
if (data.password !== data.plainPassword) {
throw new Error("Passwords do not match");
}
if (!ValidatePassword(data.password)) {
throw new Error(
"Password must contain at least 8 characters, one uppercase letter, one lowercase letter and one number",
);
}
let user = {
email: data.email,
password_hash: await HashPassword(data.password),
name: data.name,
role: data.role,
};
return await db.insertUser(user);
};
export const DeleteMonitorCompletelyUsingTag = async (tag) => {
await db.deleteMonitorDataByTag(tag);
await db.deleteIncidentMonitorsByTag(tag);
await db.deleteMonitorAlertsByTag(tag);
return await db.deleteMonitorsByTag(tag);
};
export const GetSiteMap = async (cookies) => {
let siteMapData = [];
let siteURLData = await GetSiteDataByKey("siteURL");
+83 -73
View File
@@ -66,90 +66,100 @@ async function manualIncident(monitor) {
}
const Minuter = async (monitor) => {
let realTimeData = {};
let manualData = {};
try {
let realTimeData = {};
let manualData = {};
const startOfMinute = GetMinuteStartNowTimestampUTC();
const serviceClient = new Service(monitor);
if (monitor.monitor_type === "API") {
let apiResponse = await serviceClient.execute();
const startOfMinute = GetMinuteStartNowTimestampUTC();
const serviceClient = new Service(monitor);
if (monitor.monitor_type === "API") {
let apiResponse = await serviceClient.execute();
realTimeData[startOfMinute] = apiResponse;
realTimeData[startOfMinute] = apiResponse;
//if timeout, retry after 500ms
if (apiResponse.type === TIMEOUT) {
apiQueue.push(async (cb) => {
await Wait(500); //wait for 500ms
console.log("Retrying api call for " + monitor.name + " at " + startOfMinute + " due to timeout");
serviceClient.execute().then(async (data) => {
await db.insertMonitoringData({
monitor_tag: monitor.tag,
timestamp: startOfMinute,
status: data.status,
latency: data.latency,
type: data.type,
//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();
});
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();
} else if (monitor.monitor_type === "GROUP") {
realTimeData[startOfMinute] = await serviceClient.execute(startOfMinute);
} else if (monitor.monitor_type === "SSL") {
realTimeData[startOfMinute] = await serviceClient.execute();
} else if (monitor.monitor_type === "SQL") {
realTimeData[startOfMinute] = await serviceClient.execute();
} else if (monitor.monitor_type === "HEARTBEAT") {
realTimeData[startOfMinute] = await serviceClient.execute();
}
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,
};
}
}
for (const timestamp in realTimeData) {
if (!!realTimeData[timestamp] && !!realTimeData[timestamp].status) {
mergedData[timestamp] = realTimeData[timestamp];
}
}
for (const timestamp in manualData) {
if (!!manualData[timestamp] && !!manualData[timestamp].status) {
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,
});
}
} 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();
} else if (monitor.monitor_type === "GROUP") {
realTimeData[startOfMinute] = await serviceClient.execute(startOfMinute);
} else if (monitor.monitor_type === "SSL") {
realTimeData[startOfMinute] = await serviceClient.execute();
} else if (monitor.monitor_type === "SQL") {
realTimeData[startOfMinute] = await serviceClient.execute();
} else if (monitor.monitor_type === "HEARTBEAT") {
realTimeData[startOfMinute] = await serviceClient.execute();
} catch (e) {
console.log(`[Error] in Running Monitor name: ${monitor.name}, tag: ${monitor.tag} error:`, e);
}
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,
};
}
}
for (const timestamp in realTimeData) {
if (!!realTimeData[timestamp] && !!realTimeData[timestamp].status) {
mergedData[timestamp] = realTimeData[timestamp];
}
}
for (const timestamp in manualData) {
if (!!manualData[timestamp] && !!manualData[timestamp].status) {
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();
try {
await alerting(monitor);
cb();
} catch (e) {
console.log(`[Error] in Running Alerting name: ${monitor.name}, tag: ${monitor.tag} error:`, e);
cb();
}
}, 1042);
});
};
+202 -4
View File
@@ -30,6 +30,14 @@ class DbImpl {
.where("timestamp", "<=", end)
.orderBy("timestamp", "asc");
}
//given monitor_tags array of string, start and end timestamp in utc seconds return data
async getMonitoringDataAll(monitor_tags, start, end) {
return await this.knex("monitoring_data")
.whereIn("monitor_tag", monitor_tags)
.where("timestamp", ">=", start)
.where("timestamp", "<=", end)
.orderBy("timestamp", "asc");
}
//get latest data for a monitor_tag
async getLatestMonitoringData(monitor_tag) {
@@ -40,6 +48,38 @@ class DbImpl {
.first();
}
//get latest data for all active monitors
async getLatestMonitoringDataAllActive(monitor_tags) {
// Find the latest timestamp for each provided monitor tag
const latestTimestamps = await this.knex("monitoring_data")
.select("monitor_tag")
.select(this.knex.raw("MAX(timestamp) as max_timestamp"))
.whereIn("monitor_tag", monitor_tags)
.groupBy("monitor_tag");
// Early exit if no results
if (!latestTimestamps || latestTimestamps.length === 0) {
return [];
}
// Then fetch the complete records using the timestamp pairs
const conditions = latestTimestamps.map((item) => {
return function () {
this.where(function () {
this.where("monitor_tag", item.monitor_tag).andWhere("timestamp", item.max_timestamp);
});
};
});
// Build query with OR conditions
let query = this.knex("monitoring_data").where(conditions[0]);
for (let i = 1; i < conditions.length; i++) {
query = query.orWhere(conditions[i]);
}
return await query;
}
async getLastHeartbeat(monitor_tag) {
return await this.knex("monitoring_data")
.where("monitor_tag", monitor_tag)
@@ -75,6 +115,15 @@ class DbImpl {
.limit(1)
.first();
}
//get the last status before the timestamp given monitor_tag and start timestamp
async getLastStatusBeforeAll(monitor_tags, timestamp) {
return await this.knex("monitoring_data")
.whereIn("monitor_tag", monitor_tags)
.where("timestamp", "<", timestamp)
.orderBy("timestamp", "desc")
.limit(1)
.first();
}
async getDataGroupByDayAlternative(monitor_tag, start, end) {
// Fetch all raw data
@@ -89,9 +138,9 @@ class DbImpl {
return data;
}
async getLastStatusBeforeCombined(monitor_tags_arr, timestamp) {
async getLastStatusBeforeCombined(monitor_tags_arr, timestamp, minTimestamp) {
//monitor_tag_arr is an array of string
return await this.knex("monitoring_data")
let query = this.knex("monitoring_data")
.select(
"timestamp",
this.knex.raw("COUNT(*) as total_entries"),
@@ -104,8 +153,15 @@ class DbImpl {
END as status
`),
)
.whereIn("monitor_tag", monitor_tags_arr)
.where("timestamp", "=", timestamp)
.whereIn("monitor_tag", monitor_tags_arr);
if (!!minTimestamp) {
query = query.whereBetween("timestamp", [minTimestamp, timestamp]);
} else {
query = query.where("timestamp", "=", timestamp);
}
return await query
.groupBy("timestamp")
.havingRaw("COUNT(*) = ?", [monitor_tags_arr.length])
.orderBy("timestamp", "desc")
@@ -268,6 +324,11 @@ class DbImpl {
return await this.knex("site_data").where("key", key).first();
}
//get site data that starts with analytics.
async getAllSiteDataAnalytics() {
return await this.knex("site_data").where("key", "like", "analytics.%");
}
//insert into monitors
async insertMonitor(data) {
return await this.knex("monitors").insert({
@@ -403,6 +464,14 @@ class DbImpl {
return await this.knex("users").select("password_hash").where("id", id).first();
}
//get user name, email from users given id
async getUserById(id) {
return await this.knex("users")
.select("id", "email", "name", "is_active", "is_verified", "role", "created_at", "updated_at")
.where("id", id)
.first();
}
//insert user
async insertUser(data) {
return await this.knex("users").insert({
@@ -423,6 +492,27 @@ class DbImpl {
});
}
//get all columns of users except password_hash order by create at
async getAllUsers() {
return await this.knex("users")
.select("id", "email", "name", "role", "is_active", "is_verified", "created_at", "updated_at")
.orderBy("created_at", "desc");
}
//get all users paginated
async getUsersPaginated(page, limit) {
return await this.knex("users")
.select("id", "email", "name", "role", "is_active", "is_verified", "created_at", "updated_at")
.orderBy("created_at", "desc")
.limit(limit)
.offset((page - 1) * limit);
}
//getTotalUsers
async getTotalUsers() {
return await this.knex("users").count("* as count").first();
}
//new api key
async createNewApiKey(data) {
return await this.knex("api_keys").insert({
@@ -742,6 +832,114 @@ class DbImpl {
async getIncidentCommentByID(id) {
return await this.knex("incident_comments").where({ id }).first();
}
//update users.name by id
async updateUserName(id, name) {
return await this.knex("users").where({ id }).update({
name,
updated_at: this.knex.fn.now(),
});
}
//updateUserRole
async updateUserRole(id, role) {
return await this.knex("users").where({ id }).update({
role,
updated_at: this.knex.fn.now(),
});
}
//update is_active
async updateUserIsActive(id, is_active) {
return await this.knex("users").where({ id }).update({
is_active,
updated_at: this.knex.fn.now(),
});
}
//update password
async updateUserPassword(data) {
return await this.knex("users").where({ id: data.id }).update({
password_hash: data.password_hash,
updated_at: this.knex.fn.now(),
});
}
async updateIsVerified(id, is_verified) {
return await this.knex("users").where({ id }).update({
is_verified: is_verified,
updated_at: this.knex.fn.now(),
});
}
//insert into invitations
async insertInvitation(data) {
return await this.knex("invitations").insert({
invitation_token: data.invitation_token,
invitation_type: data.invitation_type,
invited_user_id: data.invited_user_id,
invited_by_user_id: data.invited_by_user_id,
invitation_meta: data.invitation_meta,
invitation_expiry: data.invitation_expiry,
invitation_status: data.invitation_status,
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
});
}
//update invitation_status of all invitations for invited_user_id, given invitation_type to VOID
async updateInvitationStatusToVoid(invited_user_id, invitation_type) {
return await this.knex("invitations").where({ invited_user_id, invitation_type }).update({
invitation_status: "VOID",
updated_at: this.knex.fn.now(),
});
}
//check if there is a row for given invited_user_id,invitation_type and invitation_status = PENDING
async invitationExists(invited_user_id, invitation_type) {
const result = await this.knex("invitations")
.count("* as count")
.where({
invited_user_id,
invitation_type,
invitation_status: "PENDING",
})
.first();
return result.count > 0;
}
//update invitation_status of invitation given invitation_token to ACCEPTED
async updateInvitationStatusToAccepted(invitation_token) {
return await this.knex("invitations").where({ invitation_token }).update({
invitation_status: "ACCEPTED",
updated_at: this.knex.fn.now(),
});
}
//get invitations by token, the invitation should be PENDING, and it should not be expired
async getActiveInvitationByToken(invitation_token) {
return await this.knex("invitations")
.where("invitation_token", invitation_token)
.andWhere("invitation_status", "PENDING")
.andWhere("invitation_expiry", ">", this.knex.fn.now())
.first();
}
//delete from monitor_data using tag
async deleteMonitorDataByTag(tag) {
return await this.knex("monitoring_data").where("monitor_tag", tag).del();
}
//delete from incident monitors using tag
async deleteIncidentMonitorsByTag(tag) {
return await this.knex("incident_monitors").where("monitor_tag", tag).del();
}
//delete from monitor alerts using tag
async deleteMonitorAlertsByTag(tag) {
return await this.knex("monitor_alerts").where("monitor_tag", tag).del();
}
//delete from monitors using tag
async deleteMonitorsByTag(tag) {
return await this.knex("monitors").where("tag", tag).del();
}
}
export default DbImpl;
+15 -2
View File
@@ -43,8 +43,20 @@ const seedSiteData = {
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',
footerHTML: `<div
class="container relative mt-4 max-w-[655px]"
>
<div class="flex flex-col items-center gap-4 px-8 md:flex-row md:gap-2 md:px-0">
<p class="text-center text-sm leading-loose text-muted-foreground ">
Made using
<a href="https://github.com/rajnandan1/kener" target="_blank" class="font-medium underline underline-offset-4">
Kener
</a>
an open source status page system built with Svelte and TailwindCSS.<br/>
Created with by <a href="https://rajnandan.com" target="_blank" class="font-medium underline underline-offset-4">Raj Nandan Sharma</a>.
</p>
</div>
</div>`,
i18n: {
defaultLocale: "en",
locales: [{ code: "en", name: "English", selected: true, disabled: false }],
@@ -88,6 +100,7 @@ const seedSiteData = {
categories: [{ name: "Home", description: "Monitors for Home Page" }],
homeIncidentCount: 5,
homeIncidentStartTimeWithin: 30,
kenerTheme: "default",
};
export default seedSiteData;
+33 -60
View File
@@ -1,15 +1,22 @@
// @ts-nocheck
import Mustache from "mustache";
import { DiscordJSONTemplate } from "../../anywhere.js";
import variables from "./variables.js";
import { GetRequiredSecrets, ReplaceAllOccurrences } from "../tool.js";
import version from "../../version.js";
class Discord {
url;
headers;
method;
siteData;
monitorData;
trigger_meta;
constructor(url, siteData, monitorData) {
constructor(url, siteData, monitorData, trigger_meta) {
const kenerHeader = {
"Content-Type": "application/json",
"User-Agent": "Kener",
"User-Agent": `Kener/${version()}`,
};
this.url = url;
@@ -17,77 +24,43 @@ class Discord {
this.method = "POST";
this.siteData = siteData;
this.monitorData = monitorData;
this.trigger_meta = trigger_meta;
if (!!this.trigger_meta.has_discord_body && !!this.trigger_meta.discord_body) {
let envSecrets = GetRequiredSecrets(this.trigger_meta.discord_body);
for (let i = 0; i < envSecrets.length; i++) {
const secret = envSecrets[i];
this.trigger_meta.discord_body = ReplaceAllOccurrences(
this.trigger_meta.discord_body,
secret.find,
secret.replace,
);
}
}
}
transformData(data) {
let siteURL = this.siteData.siteURL;
let view = variables(this.siteData, data);
//remove / if there at the end of the url
if (siteURL.endsWith("/")) {
siteURL = siteURL.slice(0, -1);
let discordTemplate = DiscordJSONTemplate;
if (!!this.trigger_meta.has_discord_body && !!this.trigger_meta.discord_body) {
discordTemplate = this.trigger_meta.discord_body;
}
if (!this.siteData.logo.startsWith("http"))
this.siteData.logo = `${siteURL}${!!process.env.KENER_BASE_PATH ? process.env.KENER_BASE_PATH : ""}${this.siteData.logo}`;
let logo = this.siteData.logo;
let color = 13250616; //down;
if (data.severity === "warning") {
color = 15125089;
}
if (data.status === "RESOLVED") {
color = 5156244;
}
return {
username: this.siteData.siteName,
avatar_url: logo,
content: `## ${data.alert_name}\n${data.status === "TRIGGERED" ? "🔴 Triggered" : "🟢 Resolved"}\n${data.description}\nClick [here](${data.actions[0].url}) for more.`,
embeds: [
{
title: data.alert_name,
description: data.description,
url: data.actions[0].url,
color: color,
fields: [
{
name: "Monitor",
value: data.details.metric,
inline: false,
},
{
name: "Alert ID",
value: data.id,
inline: false,
},
{
name: "Current Value",
value: data.details.current_value,
inline: true,
},
{
name: "Threshold",
value: data.details.threshold,
inline: true,
},
],
footer: {
text: "Kener",
icon_url: logo,
},
timestamp: data.timestamp,
},
],
};
return Mustache.render(discordTemplate, view);
}
async send(data) {
try {
const toSend = this.transformData(data);
const response = await fetch(this.url, {
method: this.method,
headers: this.headers,
body: JSON.stringify(this.transformData(data)),
body: toSend,
});
if (!response.ok) {
throw new Error(`Error from discord`);
}
return response;
} catch (error) {
console.error("Error sending webhook", error);
+76 -75
View File
@@ -3,56 +3,57 @@ import { Resend } from "resend";
import nodemailer from "nodemailer";
import getSMTPTransport from "./smtps.js";
import { GetRequiredSecrets, ReplaceAllOccurrences } from "../tool.js";
import version from "../../version.js";
class Email {
to;
from;
method;
siteData;
monitorData;
meta;
to;
from;
method;
siteData;
monitorData;
meta;
constructor(meta, siteData, monitorData) {
this.to = meta.to;
this.from = meta.from;
this.siteData = siteData;
this.monitorData = monitorData;
constructor(meta, siteData, monitorData) {
this.to = meta.to;
this.from = meta.from;
this.siteData = siteData;
this.monitorData = monitorData;
let metaString = JSON.stringify(meta);
let metaString = JSON.stringify(meta);
let envSecrets = GetRequiredSecrets(`${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);
}
for (let i = 0; i < envSecrets.length; i++) {
const secret = envSecrets[i];
metaString = ReplaceAllOccurrences(metaString, secret.find, secret.replace);
}
this.meta = JSON.parse(metaString);
}
this.meta = JSON.parse(metaString);
}
transformData(data) {
let ctaLink = data.actions[0].url;
let ctaText = data.actions[0].text;
let emailApiRequest = {
from: this.from,
to: this.to.split(",").map((email) => email.trim()),
subject: `[${data.status}] ${data.alert_name} at ${data.timestamp}`,
text: `Alert ${data.alert_name} has been ${data.status} at ${data.timestamp}. Click here to view the alert: ${ctaLink}`,
html: ""
};
transformData(data) {
let ctaLink = data.actions[0].url;
let ctaText = data.actions[0].text;
let emailApiRequest = {
from: this.from,
to: this.to.split(",").map((email) => email.trim()),
subject: `[${data.status}] ${data.alert_name} at ${data.timestamp}`,
text: `Alert ${data.alert_name} has been ${data.status} at ${data.timestamp}. Click here to view the alert: ${ctaLink}`,
html: "",
};
let bgColor = "#f4f4f4";
if (data.severity == "critical") {
bgColor = this.siteData.colors.DOWN;
} else if (data.severity == "warning") {
bgColor = this.siteData.colors.DEGRADED;
}
let bgColor = "#f4f4f4";
if (data.severity == "critical") {
bgColor = this.siteData.colors.DOWN;
} else if (data.severity == "warning") {
bgColor = this.siteData.colors.DEGRADED;
}
if (data.status === "RESOLVED") {
bgColor = this.siteData.colors.UP;
}
if (data.status === "RESOLVED") {
bgColor = this.siteData.colors.UP;
}
let html = `
let html = `
<!DOCTYPE html>
<html>
<head>
@@ -177,53 +178,53 @@ class Email {
</table>
<div class="footer">
This is an automated alert notification from ${this.siteData.siteName} monitoring system.
This is an automated alert notification from ${this.siteData.siteName} monitoring system. It is being powered by <a href='https://kener.ing'>Kener</a> v${version()}.
</div>
</div>
</body>
</html>
`;
emailApiRequest.html = html;
emailApiRequest.html = html;
return emailApiRequest;
}
return emailApiRequest;
}
type() {
return "email";
}
type() {
return "email";
}
async send(data) {
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);
async send(data) {
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);
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)
};
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;
}
}
}
try {
return await transporter.sendMail(mailOptions);
} catch (error) {
console.error("Error sending email via SMTP", error);
return error;
}
}
}
}
export default Email;
+19 -19
View File
@@ -5,26 +5,26 @@ import Slack from "./slack.js";
import Email from "./email.js";
class Notification {
client;
client;
constructor(trigger, siteData, monitorData) {
let trigger_meta = JSON.parse(trigger.trigger_meta);
if (trigger.trigger_type === "webhook") {
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") {
this.client = new Slack(trigger_meta.url, siteData, monitorData);
} else if (trigger.trigger_type === "email") {
this.client = new Email(trigger_meta, siteData, monitorData);
} else {
console.log("Invalid Notification");
process.exit(1);
}
}
constructor(trigger, siteData, monitorData) {
let trigger_meta = JSON.parse(trigger.trigger_meta);
if (trigger.trigger_type === "webhook") {
this.client = new Webhook(trigger_meta, "POST", siteData, monitorData);
} else if (trigger.trigger_type === "discord") {
this.client = new Discord(trigger_meta.url, siteData, monitorData, trigger_meta);
} else if (trigger.trigger_type === "slack") {
this.client = new Slack(trigger_meta.url, siteData, monitorData, trigger_meta);
} else if (trigger.trigger_type === "email") {
this.client = new Email(trigger_meta, siteData, monitorData);
} else {
console.log("Invalid Notification");
process.exit(1);
}
}
async send(data) {
return await this.client.send(data);
}
async send(data) {
return await this.client.send(data);
}
}
export default Notification;
+57 -91
View File
@@ -1,100 +1,66 @@
// @ts-nocheck
import variables from "./variables.js";
import { SlackJSONTemplate } from "../../anywhere.js";
import Mustache from "mustache";
import { GetRequiredSecrets, ReplaceAllOccurrences } from "../tool.js";
import version from "../../version.js";
class Slack {
url;
headers;
method;
siteData;
monitorData;
url;
headers;
method;
siteData;
monitorData;
trigger_meta;
constructor(url, siteData, monitorData) {
const kenerHeader = {
"Content-Type": "application/json",
"User-Agent": "Kener"
};
constructor(url, siteData, monitorData, trigger_meta) {
const kenerHeader = {
"Content-Type": "application/json",
"User-Agent": `Kener/${version()}`,
};
this.url = url;
this.headers = Object.assign(kenerHeader, {});
this.method = "POST";
this.siteData = siteData;
this.monitorData = monitorData;
}
this.url = url;
this.headers = Object.assign(kenerHeader, {});
this.method = "POST";
this.siteData = siteData;
this.monitorData = monitorData;
this.trigger_meta = trigger_meta;
transformData(alert) {
return {
blocks: [
{
type: "header",
text: {
type: "plain_text",
text: alert.alert_name,
emoji: true
}
},
{
type: "header",
text: {
type: "plain_text",
text: alert.status === "TRIGGERED" ? "🔴 Triggered" : "🟢 Resolved",
emoji: true
}
},
{
type: "section",
text: {
type: "mrkdwn",
text: `${alert.description}\n*Source:* ${alert.source}\n*Severity:* ${alert.severity}\n*Status:* ${alert.status}`
}
},
{
type: "section",
fields: [
{
type: "mrkdwn",
text: `*Metric:*\n${alert.details.metric}`
},
{
type: "mrkdwn",
text: `*Current Value:*\n${alert.details.current_value}`
},
{
type: "mrkdwn",
text: `*Threshold:*\n${alert.details.threshold}`
},
{
type: "mrkdwn",
text: `*Timestamp:*\n<!date^${Math.floor(new Date(alert.timestamp).getTime() / 1000)}^{date} at {time}|${alert.timestamp}>`
}
]
},
{
type: "actions",
elements: alert.actions.map((action) => ({
type: "button",
text: {
type: "plain_text",
text: action.text
},
url: action.url,
style: "primary"
}))
}
]
};
}
if (!!this.trigger_meta.has_slack_body && !!this.trigger_meta.slack_body) {
let envSecrets = GetRequiredSecrets(this.trigger_meta.slack_body);
for (let i = 0; i < envSecrets.length; i++) {
const secret = envSecrets[i];
this.trigger_meta.slack_body = ReplaceAllOccurrences(this.trigger_meta.slack_body, secret.find, secret.replace);
}
}
}
async send(data) {
try {
const response = await fetch(this.url, {
method: this.method,
headers: this.headers,
body: JSON.stringify(this.transformData(data))
});
return response;
} catch (error) {
console.error("Error sending webhook", error);
return error;
}
}
transformData(alert) {
let view = variables(this.siteData, alert);
let slackTemplate = SlackJSONTemplate;
if (!!this.trigger_meta.has_slack_body && !!this.trigger_meta.slack_body) {
slackTemplate = this.trigger_meta.slack_body;
}
return Mustache.render(slackTemplate, view);
}
async send(data) {
try {
const response = await fetch(this.url, {
method: this.method,
headers: this.headers,
body: this.transformData(data),
});
if (!response.ok) {
throw new Error(`Error from slack`);
}
return response;
} catch (error) {
console.error("Error sending webhook", error);
return error;
}
}
}
export default Slack;
+1 -1
View File
@@ -1,6 +1,6 @@
// @ts-nocheck
import nodemailer from "nodemailer";
import { HashString } from "../controllers/controller.js";
import { HashString } from "../tool.js";
const transports = {};
export default function getSMTPTransport(meta) {
//convert meta to string and generate has id
+33
View File
@@ -0,0 +1,33 @@
// @ts-nocheck
export default function variables(siteData, data) {
let siteURL = siteData.siteURL;
let siteName = siteData.siteName;
if (siteURL.endsWith("/")) {
siteURL = siteURL.slice(0, -1);
}
if (!siteData.logo.startsWith("http")) {
siteData.logo = `${siteURL}${!!process.env.KENER_BASE_PATH ? process.env.KENER_BASE_PATH : ""}${siteData.logo}`;
}
let view = {
site_name: siteName,
logo_url: siteData.logo,
site_url: siteURL,
alert_name: data.alert_name,
status: data.status,
is_resolved: data.status === "RESOLVED",
is_triggered: data.status === "TRIGGERED",
description: data.description,
action_text: data.actions[0].text,
action_url: data.actions[0].url,
metric: data.details.metric,
severity: data.severity,
id: data.id,
current_value: data.details.current_value,
threshold: data.details.threshold,
source: data.source,
timestamp: data.timestamp,
};
return view;
}
+18 -4
View File
@@ -1,6 +1,10 @@
// @ts-nocheck
import { GetRequiredSecrets, ReplaceAllOccurrences } from "../tool.js";
import Mustache from "mustache";
import { WebhookJSONTemplate } from "../../anywhere.js";
import variables from "./variables.js";
import version from "../../version.js";
class Webhook {
url;
@@ -13,7 +17,7 @@ class Webhook {
constructor(trigger_meta, method, siteData, monitorData) {
const kenerHeader = {
"Content-Type": "application/json",
"User-Agent": "Kener/3.2.2",
"User-Agent": `Kener/${version()}`,
};
let headers = trigger_meta.headers;
this.trigger_meta = trigger_meta;
@@ -52,8 +56,13 @@ class Webhook {
}
transformData(data) {
if (!!!this.trigger_meta.has_webhook_body) return JSON.stringify(data);
if (!!!this.trigger_meta.webhook_body) return JSON.stringify(data);
let view = variables(this.siteData, data);
if (!!!this.trigger_meta.has_webhook_body || !!!this.trigger_meta.webhook_body) {
return Mustache.render(WebhookJSONTemplate, view);
}
//have to keep this for backward compatibility
let body = this.trigger_meta.webhook_body;
body = ReplaceAllOccurrences(body, "${id}", data.id);
body = ReplaceAllOccurrences(body, "${alert_name}", data.alert_name);
@@ -67,7 +76,9 @@ class Webhook {
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;
//have to keep this for backward compatibility
return Mustache.render(body, view);
}
type() {
@@ -81,6 +92,9 @@ class Webhook {
headers: this.headers,
body: this.transformData(data),
});
if (!response.ok) {
throw new Error(`Error from webhook`);
}
return response;
} catch (error) {
console.error("Error sending webhook", error);
+64 -63
View File
@@ -3,85 +3,86 @@ 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;
timeout = Number(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 });
}
};
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"));
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);
});
// 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
};
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;
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
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
// 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(":");
// 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
// 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 };
+10 -17
View File
@@ -3,20 +3,9 @@ import axios from "axios";
import { GetRequiredSecrets, ReplaceAllOccurrences } from "../tool.js";
import { UP, DOWN, DEGRADED, REALTIME, TIMEOUT, ERROR, MANUAL } from "../constants.js";
import * as cheerio from "cheerio";
const defaultEval = `(async function (statusCode, responseTime, responseRaw, modules) {
let statusCodeShort = Math.floor(statusCode/100);
if(statusCode == 429 || (statusCodeShort >=2 && statusCodeShort <= 3)) {
return {
status: 'UP',
latency: responseTime,
}
}
return {
status: 'DOWN',
latency: responseTime,
}
})`;
import { DefaultAPIEval } from "../../anywhere.js";
import version from "../../version.js";
import https from "https";
class ApiCall {
monitor;
@@ -31,7 +20,7 @@ class ApiCall {
async execute() {
let axiosHeaders = {};
axiosHeaders["User-Agent"] = "Kener/" + "3.1.0";
axiosHeaders["User-Agent"] = `Kener/${version()}`;
axiosHeaders["Accept"] = "*/*";
let body = this.monitor.type_data.body;
@@ -44,10 +33,10 @@ class ApiCall {
}
let method = this.monitor.type_data.method;
let timeout = this.monitor.type_data.timeout || 5000;
let timeout = this.monitor.type_data.timeout || 10000;
let tag = this.monitor.tag;
let monitorEval = !!this.monitor.type_data.eval ? this.monitor.type_data.eval : defaultEval;
let monitorEval = !!this.monitor.type_data.eval ? this.monitor.type_data.eval : DefaultAPIEval;
for (let i = 0; i < this.envSecrets.length; i++) {
const secret = this.envSecrets[i];
@@ -82,6 +71,10 @@ class ApiCall {
transformResponse: (r) => r,
};
if (!!this.monitor.type_data.allowSelfSignedCert) {
options.httpsAgent = new https.Agent({ rejectUnauthorized: false });
}
if (!!body) {
options.data = body;
}
+9 -1
View File
@@ -1,16 +1,21 @@
// @ts-nocheck
import axios from "axios";
import { GetRequiredSecrets, ReplaceAllOccurrences, Wait } from "../tool.js";
import { GetRequiredSecrets, ReplaceAllOccurrences, Wait, GetMinuteStartNowTimestampUTC } from "../tool.js";
import { UP, DOWN, DEGRADED, REALTIME, TIMEOUT, ERROR, MANUAL } from "../constants.js";
import db from "../db/db.js";
async function waitForDataAndReturn(arr, ts, d, maxTime) {
await Wait(d);
let data = await db.getLastStatusBeforeCombined(arr, ts);
if (data) {
data.type = REALTIME;
return data;
} else if (d > maxTime) {
data = await db.getLastStatusBeforeCombined(arr, ts, ts - 86400);
if (data) {
return data;
}
return {
status: DOWN,
latency: 0,
@@ -29,6 +34,9 @@ class GroupCall {
}
async execute(startOfMinute) {
if (!!!startOfMinute) {
startOfMinute = GetMinuteStartNowTimestampUTC();
}
let tagArr = this.monitor.type_data.monitors.map((m) => m.tag);
return await waitForDataAndReturn(tagArr, startOfMinute, 500, this.monitor.type_data.timeout);
}
+2 -16
View File
@@ -2,21 +2,7 @@
import axios from "axios";
import { Ping } from "../ping.js";
import { UP, DOWN, DEGRADED, REALTIME, TIMEOUT, ERROR, MANUAL } from "../constants.js";
const defaultPingEval = `(async function (arrayOfPings) {
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
return acc + ping.latency;
}, 0);
let alive = arrayOfPings.reduce((acc, ping) => {
return acc && ping.alive;
}, true);
return {
status: alive ? 'UP' : 'DOWN',
latency: latencyTotal / arrayOfPings.length,
}
})`;
import { DefaultPingEval } from "../../anywhere.js";
class PingCall {
monitor;
@@ -27,7 +13,7 @@ class PingCall {
async execute() {
let hosts = this.monitor.type_data.hosts;
let pingEval = !!this.monitor.type_data.pingEval ? this.monitor.type_data.pingEval : defaultPingEval;
let pingEval = !!this.monitor.type_data.pingEval ? this.monitor.type_data.pingEval : DefaultPingEval;
let tag = this.monitor.tag;
if (hosts === undefined) {
console.log(
+2 -20
View File
@@ -2,25 +2,7 @@
import axios from "axios";
import { TCP } from "../ping.js";
import { UP, DOWN, DEGRADED, REALTIME, TIMEOUT, ERROR, MANUAL } from "../constants.js";
const defaultTcpEval = `(async function (arrayOfPings) {
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,
}
})`;
import { DefaultTCPEval } from "../../anywhere.js";
class TcpCall {
monitor;
@@ -31,7 +13,7 @@ class TcpCall {
async execute() {
let hosts = this.monitor.type_data.hosts;
let tcpEval = !!this.monitor.type_data.tcpEval ? this.monitor.type_data.tcpEval : defaultTcpEval;
let tcpEval = !!this.monitor.type_data.tcpEval ? this.monitor.type_data.tcpEval : DefaultTCPEval;
let tag = this.monitor.tag;
if (hosts === undefined) {
+7 -5
View File
@@ -1,14 +1,15 @@
// @ts-nocheck
import figlet from "figlet";
import { Cron } from "croner";
import { Minuter } from "./cron-minute.js";
import db from "./db/db.js";
import { GetAllSiteData, GetMonitorsParsed, HashString } from "./controllers/controller.js";
import { GetAllSiteData, GetMonitorsParsed } from "./controllers/controller.js";
import { HashString } from "./tool.js";
import { fileURLToPath } from "url";
import { dirname } from "path";
import { dirname, resolve } from "path";
import fs from "fs";
import version from "../version.js";
const jobs = [];
process.env.TZ = "UTC";
@@ -72,12 +73,13 @@ async function Startup() {
mainJob.trigger();
figlet("Kener is UP!", function (err, data) {
figlet("Kener v" + version(), function (err, data) {
if (err) {
console.log("Something went wrong...");
return;
}
console.log(data);
console.log(`Kener version ${version()} is running!`);
});
}
+217
View File
@@ -0,0 +1,217 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<link rel="preload" as="image" href="{{logo_url}}" />
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
<!--$-->
</head>
<div
style="
display: none;
overflow: hidden;
line-height: 1px;
opacity: 0;
max-height: 0;
max-width: 0;
"
>
Reset your {{brand_name}} password
</div>
<body
style="
background-color: rgb(243, 244, 246);
font-family:
ui-sans-serif, system-ui, sans-serif, &quot;Apple Color Emoji&quot;,
&quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;;
"
>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
margin-left: auto;
margin-right: auto;
margin-top: 40px;
margin-bottom: 40px;
max-width: 600px;
border-radius: 0.25rem;
background-color: rgb(255, 255, 255);
padding: 20px;
"
>
<tbody>
<tr style="width: 100%">
<td>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="text-align: center"
>
<tbody>
<tr>
<td>
<img
alt="{{brand_name}} Logo"
height="50"
src="{{logo_url}}"
style="
margin-left: auto;
margin-right: auto;
margin-top: 20px;
margin-bottom: 20px;
display: block;
outline: none;
border: none;
text-decoration: none;
"
width="200"
/>
</td>
</tr>
</tbody>
</table>
<h1
style="
font-size: 24px;
font-weight: 700;
color: rgb(31, 41, 55);
text-align: center;
margin-top: 30px;
margin-bottom: 30px;
"
>
Password Reset Request
</h1>
<p
style="
font-size: 16px;
line-height: 24px;
color: rgb(75, 85, 99);
margin-bottom: 24px;
margin: 16px 0;
"
>
Hello,
</p>
<p
style="
font-size: 16px;
line-height: 24px;
color: rgb(75, 85, 99);
margin-bottom: 24px;
margin: 16px 0;
"
>
We received a request to reset your password for your {{brand_name}} account. If you
didn&#x27;t make this request, you can safely ignore this email.
</p>
<p
style="
font-size: 16px;
line-height: 24px;
color: rgb(75, 85, 99);
margin-bottom: 32px;
margin: 16px 0;
"
>
To reset your password, please click the button below:
</p>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="text-align: center; margin-bottom: 32px"
>
<tbody>
<tr>
<td>
<a
href="{{link}}"
style="
background-color: rgb(37, 99, 235);
color: rgb(255, 255, 255);
font-weight: 700;
padding-top: 12px;
padding-bottom: 12px;
padding-left: 24px;
padding-right: 24px;
border-radius: 0.25rem;
box-sizing: border-box;
line-height: 100%;
text-decoration: none;
display: inline-block;
max-width: 100%;
mso-padding-alt: 0px;
padding: 12px 24px 12px 24px;
"
target="_blank"
><span
><!--[if mso
]><i style="mso-font-width: 400%; mso-text-raise: 18" hidden
>&#8202;&#8202;&#8202;</i
><!
[endif]--></span
><span
style="
max-width: 100%;
display: inline-block;
line-height: 120%;
mso-padding-alt: 0px;
mso-text-raise: 9px;
"
>Reset Password</span
><span
><!--[if mso
]><i style="mso-font-width: 400%" hidden
>&#8202;&#8202;&#8202;&#8203;</i
><!
[endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<p
style="
font-size: 16px;
line-height: 24px;
color: rgb(75, 85, 99);
margin-bottom: 24px;
margin: 16px 0;
"
>
This password reset link will expire in 24 hours. If you need assistance, please
contact our support team.
</p>
<p
style="
font-size: 16px;
line-height: 24px;
color: rgb(75, 85, 99);
margin-bottom: 32px;
margin: 16px 0;
"
>
Thank you,<br />The {{brand_name}} Team
</p>
</td>
</tr>
</tbody>
</table>
<!--/$-->
</body>
</html>
@@ -0,0 +1,221 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<link rel="preload" as="image" href="{{logo_url}}" />
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
<!--$-->
</head>
<div
style="
display: none;
overflow: hidden;
line-height: 1px;
opacity: 0;
max-height: 0;
max-width: 0;
"
>
Verify your email address for {{brand_name}}
</div>
<body
style="
background-color: rgb(243, 244, 246);
font-family:
ui-sans-serif, system-ui, sans-serif, &quot;Apple Color Emoji&quot;,
&quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;;
"
>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
margin-left: auto;
margin-right: auto;
margin-top: 40px;
margin-bottom: 40px;
background-color: rgb(255, 255, 255);
padding: 20px;
border-radius: 8px;
box-shadow:
var(--tw-ring-offset-shadow, 0 0 #0000),
var(--tw-ring-shadow, 0 0 #0000),
0 1px 2px 0 rgb(0, 0, 0, 0.05);
max-width: 37.5em;
"
>
<tbody>
<tr style="width: 100%">
<td>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="margin-top: 32px"
>
<tbody>
<tr>
<td>
<img
alt="{{brand_name}}"
height="auto"
src="{{logo_url}}"
style="
margin-left: auto;
margin-right: auto;
display: block;
outline: none;
border: none;
text-decoration: none;
"
width="120"
/>
</td>
</tr>
</tbody>
</table>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="margin-top: 32px"
>
<tbody>
<tr>
<td>
<h1
style="
font-size: 24px;
font-weight: 700;
text-align: center;
color: rgb(31, 41, 55);
"
>
Verify Your Email Address
</h1>
<p
style="
font-size: 16px;
color: rgb(75, 85, 99);
margin-bottom: 24px;
line-height: 24px;
margin: 16px 0;
"
>
Thank you for signing up with
<!-- -->{{brand_name}}<!-- -->! To complete your registration and get started,
please verify your email address.
</p>
<p
style="
font-size: 16px;
color: rgb(75, 85, 99);
margin-bottom: 32px;
line-height: 24px;
margin: 16px 0;
"
>
Click the button below to verify your email address and activate your account:
</p>
<a
href="{{verification_url}}"
style="
box-sizing: border-box;
border-radius: 4px;
font-weight: 500;
text-decoration-line: none;
text-align: center;
color: rgb(255, 255, 255);
line-height: 100%;
text-decoration: none;
display: inline-block;
max-width: 100%;
mso-padding-alt: 0px;
background-color: #ed702d;
padding: 12px 24px 12px 24px;
"
target="_blank"
><span
><!--[if mso
]><i style="mso-font-width: 400%; mso-text-raise: 18" hidden
>&#8202;&#8202;&#8202;</i
><!
[endif]--></span
><span
style="
max-width: 100%;
display: inline-block;
line-height: 120%;
mso-padding-alt: 0px;
mso-text-raise: 9px;
"
>Verify Email Address</span
><span
><!--[if mso
]><i style="mso-font-width: 400%" hidden
>&#8202;&#8202;&#8202;&#8203;</i
><!
[endif]--></span
></a
>
<p
style="
font-size: 14px;
color: rgb(107, 114, 128);
margin-top: 32px;
line-height: 24px;
margin: 16px 0;
"
>
If you didn&#x27;t create an account with
<!-- -->{{brand_name}}<!-- -->, you can safely ignore this email.
</p>
<p
style="
font-size: 14px;
color: rgb(107, 114, 128);
line-height: 24px;
margin: 16px 0;
"
>
If the button above doesn&#x27;t work, copy and paste the following link into
your browser:
</p>
<p
style="
font-size: 14px;
color: rgb(107, 114, 128);
word-break: break-all;
line-height: 24px;
margin: 16px 0;
"
>
<a
href="{{verification_url}}"
style="color: rgb(37, 99, 235); text-decoration-line: underline"
target="_blank"
>{{verification_url}}</a
>
</p>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--/$-->
</body>
</html>
+18
View File
@@ -1,6 +1,7 @@
// @ts-nocheck
import { AllRecordTypes } from "./constants.js";
import knexOb from "../../../knexfile.js";
import crypto from "crypto";
import dotenv from "dotenv";
dotenv.config();
@@ -279,6 +280,9 @@ function Wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function MaskString(str) {
if (str.length <= 4) {
return "*".repeat(str.length);
}
return "*".repeat(str.length - 4) + str.slice(-4);
}
@@ -286,6 +290,18 @@ function GetDbType() {
//sqlite, postgresql, mysql
return knexOb.databaseType;
}
function HashString(str) {
const hash = crypto.createHash("sha256");
hash.update(str);
return hash.digest("hex");
}
//function to validate email address
function ValidateEmail(email) {
const regex =
/^(?:(?:[a-zA-Z0-9!#$%&'*+\-/=?^_`{|}~]+\.)*[a-zA-Z0-9!#$%&'*+\-/=?^_`{|}~]+)@(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}|(?:\[(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)(?:\.(?!$)|$)){4}\]))$/;
return regex.test(email);
}
export {
IsValidURL,
@@ -315,4 +331,6 @@ export {
Wait,
MaskString,
GetDbType,
HashString,
ValidateEmail,
};
+42
View File
@@ -0,0 +1,42 @@
/**
* ***************************************************
* * *
* * Amplitude Analytics Capture Snippet *
* * *
* ***************************************************
**/
// @ts-nocheck
(function () {
let loadJS = function (url, implementationCode, location) {
//url is URL of external file, implementationCode is the code
//to be called from the file, location is the location to
//insert the <script> element
let scriptTag = document.createElement("script");
scriptTag.src = url;
scriptTag.onload = implementationCode;
scriptTag.onreadystatechange = implementationCode;
location.appendChild(scriptTag);
};
let initJS = function () {
window.amplitude.add(window.sessionReplay.plugin({ sampleRate: 1 }));
window.amplitude.init("{{api_key}}", { fetchRemoteConfig: true, autocapture: true });
window.addEventListener("analyticsEvent", function (e) {
// Extract event name and data from the custom event
const eventName = e.detail.event;
const eventData = e.detail.data || {};
if (eventName) {
window.amplitude.track(eventName, eventData);
}
});
};
//on dom ready
document.addEventListener("DOMContentLoaded", function () {
loadJS("https://cdn.amplitude.com/script/{{api_key}}.js", initJS, document.getElementsByTagName("head")[0]);
});
})();
+20
View File
@@ -0,0 +1,20 @@
/**
* ***************************************************
* * *
* * Clarity Analytics Capture Snippet *
* * *
* ***************************************************
**/
// @ts-nocheck
(function (c, l, a, r, i, t, y) {
c[a] =
c[a] ||
function () {
(c[a].q = c[a].q || []).push(arguments);
};
t = l.createElement(r);
t.async = 1;
t.src = "https://www.clarity.ms/tag/" + i;
y = l.getElementsByTagName(r)[0];
y.parentNode.insertBefore(t, y);
})(window, document, "clarity", "script", "{{project_id}}");
@@ -0,0 +1,48 @@
/**
* ***************************************************
* * *
* * Google Analytics Capture Snippet *
* * *
* ***************************************************
**/
// @ts-nocheck
(function () {
let loadJS = function (url, implementationCode, location) {
//url is URL of external file, implementationCode is the code
//to be called from the file, location is the location to
//insert the <script> element
let scriptTag = document.createElement("script");
scriptTag.src = url;
scriptTag.onload = implementationCode;
scriptTag.onreadystatechange = implementationCode;
location.appendChild(scriptTag);
};
let initJS = function () {
window.dataLayer = window.dataLayer || [];
function gtag() {
window.dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "{{id}}");
window.addEventListener("analyticsEvent", function (e) {
// Extract event name and data from the custom event
const eventName = e.detail.event;
const eventData = e.detail.data || {};
// Send the event to Google Tag Manager
if (window.dataLayer && eventName) {
gtag("event", eventName, eventData);
}
});
};
//on dom ready
document.addEventListener("DOMContentLoaded", function () {
loadJS("https://www.googletagmanager.com/gtag/js?id={{id}}", initJS, document.getElementsByTagName("head")[0]);
});
})();
+78
View File
@@ -0,0 +1,78 @@
/**
* ***************************************************
* * *
* * Mixpanel Analytics Capture Snippet *
* * *
* ***************************************************
**/
// @ts-nocheck
(function (f, b) {
if (!b.__SV) {
var e, g, i, h;
window.mixpanel = b;
b._i = [];
b.init = function (e, f, c) {
function g(a, d) {
var b = d.split(".");
2 == b.length && ((a = a[b[0]]), (d = b[1]));
a[d] = function () {
a.push([d].concat(Array.prototype.slice.call(arguments, 0)));
};
}
var a = b;
"undefined" !== typeof c ? (a = b[c] = []) : (c = "mixpanel");
a.people = a.people || [];
a.toString = function (a) {
var d = "mixpanel";
"mixpanel" !== c && (d += "." + c);
a || (d += " (stub)");
return d;
};
a.people.toString = function () {
return a.toString(1) + ".people (stub)";
};
i =
"disable time_event track track_pageview track_links track_forms track_with_groups add_group set_group remove_group register register_once alias unregister identify name_tag set_config reset opt_in_tracking opt_out_tracking has_opted_in_tracking has_opted_out_tracking clear_opt_in_out_tracking start_batch_senders people.set people.set_once people.unset people.increment people.append people.union people.track_charge people.clear_charges people.delete_user people.remove".split(
" ",
);
for (h = 0; h < i.length; h++) g(a, i[h]);
var j = "set set_once union unset remove delete".split(" ");
a.get_group = function () {
function b(c) {
d[c] = function () {
call2_args = arguments;
call2 = [c].concat(Array.prototype.slice.call(call2_args, 0));
a.push([e, call2]);
};
}
for (var d = {}, e = ["get_group"].concat(Array.prototype.slice.call(arguments, 0)), c = 0; c < j.length; c++)
b(j[c]);
return d;
};
b._i.push([e, f, c]);
};
b.__SV = 1.2;
e = f.createElement("script");
e.type = "text/javascript";
e.async = !0;
e.src =
"undefined" !== typeof MIXPANEL_CUSTOM_LIB_URL
? MIXPANEL_CUSTOM_LIB_URL
: "file:" === f.location.protocol && "//cdn.mxpnl.com/libs/mixpanel-2-latest.min.js".match(/^\/\//)
? "https://cdn.mxpnl.com/libs/mixpanel-2-latest.min.js"
: "//cdn.mxpnl.com/libs/mixpanel-2-latest.min.js";
g = f.getElementsByTagName("script")[0];
g.parentNode.insertBefore(e, g);
}
})(document, window.mixpanel || []);
window.mixpanel.init("{{YOUR_PROJECT_TOKEN}}", { track_pageview: true, autocapture: true });
window.addEventListener("analyticsEvent", function (e) {
const eventName = e.detail.event;
const eventData = e.detail.data || {};
if (eventName) {
window.mixpanel.track(eventName, eventData);
}
});
+47
View File
@@ -0,0 +1,47 @@
/**
* ***************************************************
* * *
* * Plausible Analytics Capture Snippet *
* * *
* ***************************************************
**/
// @ts-nocheck
(function () {
let loadJS = function (url, implementationCode, location) {
//url is URL of external file, implementationCode is the code
//to be called from the file, location is the location to
//insert the <script> element
let scriptTag = document.createElement("script");
scriptTag.src = url;
scriptTag.defer = true;
scriptTag.dataset.domain = "{{domain}}";
scriptTag.dataset.api = "{{api}}";
scriptTag.onload = implementationCode;
scriptTag.onreadystatechange = implementationCode;
location.appendChild(scriptTag);
};
let initJS = function () {
window.plausible =
window.plausible ||
function () {
(window.plausible.q = window.plausible.q || []).push(arguments);
};
window.addEventListener("analyticsEvent", function (e) {
// Extract event name and data from the custom event
const eventName = e.detail.event;
const eventData = e.detail.data || {};
if (eventName) {
window.plausible(eventName, { props: eventData });
}
});
};
//on dom ready
document.addEventListener("DOMContentLoaded", function () {
loadJS("{{script_src}}", initJS, document.getElementsByTagName("head")[0]);
});
})();
+64
View File
@@ -0,0 +1,64 @@
import { Component, Input, ElementRef, OnInit, OnDestroy, AfterViewInit } from '@angular/core';
@Component({
selector: 'app-embed-monitor',
template: `<div [id]="containerId"></div>`
})
export class EmbedMonitorComponent implements OnInit, OnDestroy, AfterViewInit {
@Input() monitor!: string;
@Input() theme: string = "light";
@Input() bgc: string = "transparent";
@Input() locale: string = "en";
containerId = `embed-container-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
private iframe: HTMLIFrameElement | null = null;
private messageListener!: (event: MessageEvent) => void;
constructor(private el: ElementRef) {}
ngOnInit() {}
ngAfterViewInit() {
this.createIframe();
}
ngOnChanges() {
this.createIframe();
}
createIframe() {
const container = this.el.nativeElement.querySelector(`#${this.containerId}`);
if (!this.monitor || !container) return;
// Remove previous iframe if it exists
if (this.iframe) {
this.iframe.remove();
}
this.iframe = document.createElement("iframe");
this.iframe.src = `${this.monitor}?theme=${this.theme}&bgc=${this.bgc}&locale=${this.locale}`;
this.iframe.width = "0%";
this.iframe.height = "0";
this.iframe.frameBorder = "0";
this.iframe.allowTransparency = true;
this.iframe.sandbox =
"allow-modals allow-forms allow-same-origin allow-scripts allow-popups allow-top-navigation-by-user-activation allow-downloads";
this.iframe.allow = "midi; geolocation; microphone; camera; display-capture; encrypted-media;";
container.appendChild(this.iframe);
this.messageListener = (event: MessageEvent) => {
if (event.data?.height) this.iframe!.height = event.data.height;
if (event.data?.width) this.iframe!.width = event.data.width;
};
window.addEventListener("message", this.messageListener);
}
ngOnDestroy() {
window.removeEventListener("message", this.messageListener);
if (this.iframe) {
this.iframe.remove();
}
}
}
+1
View File
@@ -0,0 +1 @@
<app-embed-monitor monitor="{siteURL}{base}/embed/monitor-{selectedMonitor.value}" theme="{embedTheme}" bgc="{embedBgc.substr(1)}" locale="{embedLocale.value}"></app-embed-monitor>
+8
View File
@@ -0,0 +1,8 @@
<iframe
src="{siteURL}{base}/embed/monitor-{selectedMonitor.value}?bgc={embedBgc.substr(1)}&locale={embedLocale.value}&theme={embedTheme}"
width="100%"
height="200"
allowfullscreen="allowfullscreen"
allowpaymentrequest
frameborder="0">
</iframe>
+1
View File
@@ -0,0 +1 @@
<script async src="{siteURL}{base}/embed/monitor-{selectedMonitor.value}/js?bgc={embedBgc.substr(1)}&locale={embedLocale.value}&theme={embedTheme}&monitor={siteURL}{base}/embed/monitor-{selectedMonitor.value}"></script>
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useRef, useState } from "react";
const EmbedMonitor = ({ monitor, theme = "light", bgc = "transparent", locale = "en" }) => {
const containerRef = useRef(null);
const iframeRef = useRef(null);
const [uid] = useState(`embed-container-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`);
useEffect(() => {
if (!monitor || !containerRef.current) return;
const container = containerRef.current;
// Remove previous iframe if exists
if (iframeRef.current) {
iframeRef.current.remove();
}
// Create a new iframe
const iframe = document.createElement("iframe");
iframe.src = `${monitor}?theme=${theme}&bgc=${bgc}&locale=${locale}`;
iframe.width = "0%";
iframe.height = "0";
iframe.frameBorder = "0";
iframe.allowTransparency = true;
iframe.sandbox =
"allow-modals allow-forms allow-same-origin allow-scripts allow-popups allow-top-navigation-by-user-activation allow-downloads";
iframe.allow = "midi; geolocation; microphone; camera; display-capture; encrypted-media;";
container.appendChild(iframe);
iframeRef.current = iframe;
const setHeight = (data) => {
if (data.height !== undefined) {
iframe.height = data.height;
}
};
const setWidth = (data) => {
if (data.width !== undefined) {
iframe.width = data.width;
}
};
const messageListener = (event) => {
if (event.data && event.data.height !== undefined) {
setHeight(event.data);
}
if (event.data && event.data.width !== undefined) {
setWidth(event.data);
}
};
window.addEventListener("message", messageListener);
return () => {
window.removeEventListener("message", messageListener);
if (iframe) {
iframe.remove();
}
};
}, [monitor, theme, bgc, locale]);
return <div id={uid} ref={containerRef}></div>;
};
export default EmbedMonitor;
+12
View File
@@ -0,0 +1,12 @@
import EmbedMonitor from "./EmbedMonitor";
function App() {
return (
<div>
<h2>React Embed</h2>
<EmbedMonitor monitor="{siteURL}{base}/embed/monitor-{selectedMonitor.value}" theme="{embedTheme}" bgc="{embedBgc.substr(1)}" locale="{embedLocale.value}"/>
</div>
);
}
export default App;
+91
View File
@@ -0,0 +1,91 @@
<script>
import { onMount, onDestroy, beforeUpdate } from "svelte";
export let monitor;
export let theme = "light";
export let bgc = "transparent";
export let locale = "en";
let iframe, listeners;
let containerId = `embed-container-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
let isMounted = false;
let prevMonitor, prevTheme, prevBgc, prevLocale;
onMount(() => {
if (!monitor) return;
isMounted = true;
prevMonitor = monitor;
prevTheme = theme;
prevBgc = bgc;
prevLocale = locale;
const container = document.getElementById(containerId);
if (!container) return;
const uid = "KENER_" + ~~(new Date().getTime() / 86400000);
const uriEmbedded = `${monitor}?theme=${theme}&bgc=${bgc}&locale=${locale}`;
iframe = document.createElement("iframe");
iframe.src = uriEmbedded;
iframe.id = uid;
iframe.width = "0%";
iframe.height = "0";
iframe.frameBorder = "0";
iframe.allowTransparency = true;
iframe.sandbox =
"allow-modals allow-forms allow-same-origin allow-scripts allow-popups allow-top-navigation-by-user-activation allow-downloads";
iframe.allow = "midi; geolocation; microphone; camera; display-capture; encrypted-media;";
container.appendChild(iframe);
const setHeight = (data) => {
if (data.height !== undefined) {
iframe.height = data.height;
}
};
const setWidth = (data) => {
if (data.width !== undefined) {
iframe.width = data.width;
}
};
listeners = (event) => {
if (event.data && event.data.height !== undefined) {
setHeight(event.data);
}
if (event.data && event.data.width !== undefined) {
setWidth(event.data);
}
};
window.addEventListener("message", listeners, false);
});
beforeUpdate(() => {
if (
isMounted &&
iframe &&
(monitor !== prevMonitor || theme !== prevTheme || bgc !== prevBgc || locale !== prevLocale)
) {
iframe.src = `${monitor}?theme=${theme}&bgc=${bgc}&locale=${locale}`;
// Update previous values
prevMonitor = monitor;
prevTheme = theme;
prevBgc = bgc;
prevLocale = locale;
}
});
onDestroy(() => {
if (iframe) {
window.removeEventListener("message", listeners);
iframe.remove();
}
});
</script>
<!-- Dynamic container with unique ID -->
<div id={containerId}></div>
+5
View File
@@ -0,0 +1,5 @@
<script>
import EmbedMonitor from "./EmbedMonitor.svelte";
</script>
<EmbedMonitor monitor="{siteURL}{base}/embed/monitor-{selectedMonitor.value}" theme="{embedTheme}" bgc="{embedBgc.substr(1)}" locale="{embedLocale.value}"/>
+67
View File
@@ -0,0 +1,67 @@
<template>
<div :id="containerId" ref="containerRef"></div>
</template>
<script>
import { ref, watchEffect, onMounted, onBeforeUnmount } from "vue";
export default {
props: {
monitor: String,
theme: { type: String, default: "light" },
bgc: { type: String, default: "transparent" },
locale: { type: String, default: "en" }
},
setup(props) {
const containerRef = ref(null);
const iframeRef = ref(null);
const containerId = ref(`embed-container-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`);
let messageListener = null;
const createIframe = () => {
if (!props.monitor || !containerRef.value) return;
// Remove existing iframe if any
if (iframeRef.value) {
iframeRef.value.remove();
}
const iframe = document.createElement("iframe");
iframe.src = `${props.monitor}?theme=${props.theme}&bgc=${props.bgc}&locale=${props.locale}`;
iframe.width = "0%";
iframe.height = "0";
iframe.frameBorder = "0";
iframe.allowTransparency = true;
iframe.sandbox =
"allow-modals allow-forms allow-same-origin allow-scripts allow-popups allow-top-navigation-by-user-activation allow-downloads";
iframe.allow = "midi; geolocation; microphone; camera; display-capture; encrypted-media;";
containerRef.value.appendChild(iframe);
iframeRef.value = iframe;
messageListener = (event) => {
if (event.data?.height) iframe.height = event.data.height;
if (event.data?.width) iframe.width = event.data.width;
};
window.addEventListener("message", messageListener);
};
onMounted(createIframe);
watchEffect(() => {
createIframe();
});
onBeforeUnmount(() => {
window.removeEventListener("message", messageListener);
if (iframeRef.value) {
iframeRef.value.remove();
}
});
return { containerRef, containerId };
}
};
</script>
+14
View File
@@ -0,0 +1,14 @@
<template>
<div>
<h2>Vue Embed</h2>
<EmbedMonitor monitor="{siteURL}{base}/embed/monitor-{selectedMonitor.value}" theme="{embedTheme}" bgc="{embedBgc.substr(1)}" locale="{embedLocale.value}"/>
</div>
</template>
<script>
import EmbedMonitor from "./EmbedMonitor.vue";
export default {
components: { EmbedMonitor }
};
</script>
+31
View File
@@ -0,0 +1,31 @@
import { fileURLToPath } from "url";
import { dirname, resolve } from "path";
import fs from "fs";
function getVersionUsingVite() {
try {
if (!!import.meta.env.PACKAGE_VERSION) {
return import.meta.env.PACKAGE_VERSION;
}
return null;
} catch (e) {
return null;
}
}
export default function version() {
let v = getVersionUsingVite();
if (!!v) {
return v;
} else {
try {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packagePath = resolve(__dirname, "../../package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
return packageJson.version;
} catch (e) {
return "0.0.0";
}
}
}
+2
View File
@@ -3,9 +3,11 @@
}
.copybtn .copy-btn {
transform: scale(1);
transition: transform 0.15s;
}
.copybtn .check-btn {
transform: scale(0);
transition: transform 0.15s;
}
.copybtn:focus .copy-btn {
transform: scale(0);
+4 -7
View File
@@ -2,6 +2,7 @@
import fs from "fs-extra";
import path from "path";
import { marked } from "marked";
import version from "$lib/version.js";
function extractMetadataAndContent(fileContent) {
// Regular expression to match metadata section
@@ -15,9 +16,7 @@ function extractMetadataAndContent(fileContent) {
if (match) {
// Extract metadata section and remaining content
const metadataLines = match[1]
.split("\n")
.filter((line) => line.trim() !== "");
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) => {
@@ -40,10 +39,7 @@ export async function load({ params, route, url, cookies, request }) {
docFilePath = docFolderPath + `/${docFile}.md`;
}
const fileContents = await fs.readFileSync(docFilePath, "utf-8");
const siteStructure = await fs.readFileSync(
docFolderPath + "/structure.json",
"utf-8",
);
const siteStructure = await fs.readFileSync(docFolderPath + "/structure.json", "utf-8");
const { metadata, content } = extractMetadataAndContent(fileContents);
const selectedDoc = docFilePath.replace(docFolderPath, "");
@@ -54,5 +50,6 @@ export async function load({ params, route, url, cookies, request }) {
title: metadata.title || "Kener Docs",
description: metadata.description || "Kener Docs",
siteStructure: siteStructureJSON,
kenerVersion: version(),
};
}
+6 -3
View File
@@ -11,6 +11,7 @@
import Moon from "lucide-svelte/icons/moon";
import { onMount } from "svelte";
import { base } from "$app/paths";
import { page } from "$app/stores";
let defaultTheme = "light";
export let data;
let siteStructure = data.siteStructure;
@@ -110,7 +111,9 @@
<!-- 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.2.2 </span>
<span class="me-2 rounded border px-2.5 py-0.5 text-xs font-medium">
{$page.data.kenerVersion}
</span>
</a>
</div>
@@ -202,7 +205,7 @@
</div>
<div class="flex items-center space-x-3">
<a
href="https://github.com/sponsors/rajnandan1"
href="https://buymeacoffee.com/rajnandan1"
class="rounded-md bg-amber-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-amber-600"
>
Sponsor
@@ -214,7 +217,7 @@
</div>
</div>
<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"
class="kener-md 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>
+22 -13
View File
@@ -1,7 +1,9 @@
<script>
import { onMount } from "svelte";
import { Button } from "$lib/components/ui/button";
import { ArrowLeft, ArrowRight } from "lucide-svelte";
import ArrowLeft from "lucide-svelte/icons/arrow-left";
import ArrowRight from "lucide-svelte/icons/arrow-right";
import { afterNavigate } from "$app/navigation";
export let data;
let subHeadings = [];
@@ -65,21 +67,28 @@
//function to find next and previous path
function findNextAndPreviousPath() {
let structure = data.siteStructure.sidebar;
for (let i = 0; i < structure.length; i++) {
let children = structure[i].children;
for (let j = 0; j < children.length; j++) {
if (children[j].file === data.docFilePath) {
if (j > 0) {
previousPath = children[j - 1];
}
if (j < children.length - 1) {
nextPath = children[j + 1];
}
break;
}
// Flatten the structure - combine all children arrays into one
const flattenedPages = [];
for (let i = 0; i < structure.length; i++) {
const section = structure[i];
if (section.children && Array.isArray(section.children)) {
flattenedPages.push(...section.children);
}
}
// Find current page index in the flattened array
const currentIndex = flattenedPages.findIndex((page) => page.file === data.docFilePath);
// Set previous and next paths if they exist
if (currentIndex > 0) {
previousPath = flattenedPages[currentIndex - 1];
}
if (currentIndex < flattenedPages.length - 1) {
nextPath = flattenedPages[currentIndex + 1];
}
}
afterNavigate(() => {

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