Compare commits

..

22 Commits

Author SHA1 Message Date
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
Raj Nandan Sharma 1609b4fc50 refactor: remove unnecessary future and ongoing incident checks from IncidentNew component 2025-03-08 22:56:57 +05:30
Raj Nandan Sharma dfffffb101 fix: remove database directory writable check from entrypoint script 2025-03-08 22:26:14 +05:30
Raj Nandan Sharma 81250a117a chore: update version to 3.2.2 in package.json and documentation 2025-03-08 22:18:53 +05:30
Raj Nandan Sharma 72bd0241e9 fix: streamline incident creation logic for database compatibility , fixes #325 2025-03-08 22:17:48 +05:30
Raj Nandan Sharma 63c24f147f fix: remove redundant startup command in entrypoint script #338 2025-03-08 21:19:46 +05:30
Raj Nandan Sharma 88fb7df3f5 Enhances incident display with time status
Improves the incident display by adding time status information
such as "Starts in", "Started", and "Will last for".
Also fixes database directory write permissions on startup.

Also fixes #337
2025-03-08 21:17:34 +05:30
Raj Nandan Sharma 320b1a0cc5 Improves webhook and notification handling
- Makes the monitor tags wrap on smaller screens.
- Validates webhook body.
- Adds user agent to webhook.
- Fixes Discord logo URL construction.

Issue #336
2025-03-07 20:09:35 +05:30
Raj Nandan Sharma 08b168ee6e Merge pull request #331 from rajnandan1/feature/hb1
Adds heartbeat monitor and improvements
2025-03-01 21:56:21 +05:30
Raj Nandan Sharma d6a87ac81a docs: update heartbeat monitors description for clarity
refactor: remove unused RandomString import from monitorSheet component

chore: clean up commented-out code in FetchData function
2025-03-01 21:55:37 +05:30
Raj Nandan Sharma 615dba42b8 fix: change default monitor type from HEARTBEAT to NONE 2025-03-01 21:02:12 +05:30
Raj Nandan Sharma 3ffec4f1fe Adds heartbeat monitor and improvements
Implements push-based monitoring via heartbeats.

Fixes data interpolation issues.

Enhances UI and documentation.
2025-03-01 21:00:05 +05:30
Raj Nandan Sharma 60b4b6f207 Merge pull request #327 from rajnandan1/feature/hb
feat: enhance documentation and add donation banner for Kener
2025-02-28 10:00:37 +05:30
Raj Nandan Sharma b9f5eb56c5 feat: enhance documentation and add donation banner for Kener 2025-02-28 09:59:39 +05:30
github-actions 7b9ae73044 Auto-generate README.md with release versions 2025-02-27 05:47:27 +00:00
54 changed files with 1578 additions and 1024 deletions
+32 -16
View File
@@ -58,10 +58,10 @@ 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 && \
# TODO: Consider changing below to `chmod -R u-rwX,g=rX,o= uploads database`
chmod -R 750 uploads database
# Build the application and remove `devDependencies`
@@ -78,15 +78,15 @@ 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 && \
# TODO: Is it ok to change to `curl` here so that we don't have to maintain `wget` version mismatch between Debian architectures? (`curl` is only used for the container healthcheck and because there is an Alpine variant (best!) we probably don't care if the Debian image ends up building bigger due to `curl`.)
curl=7.88.1-10+deb12u8 && \
rm -rf /var/lib/apt/lists/*
FROM node:${ALPINE_VERSION} AS final-alpine
RUN apk add --no-cache --update \
iputils=20240905-r0 \
sqlite=3.48.0-r0 \
tzdata
iputils=20240905-r0 \
sqlite=3.48.0-r0 \
tzdata
FROM final-${VARIANT} AS final
@@ -120,25 +120,41 @@ 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
# Install libcap tools before setting capabilities
RUN if [ -f "/etc/alpine-release" ]; then \
apk add --no-cache libcap; \
else \
apt-get update && apt-get install -y libcap2-bin && rm -rf /var/lib/apt/lists/*; \
fi
# Set capabilities for ping (before changing to non-root user)
RUN if [ -f "/etc/alpine-release" ]; then \
# Alpine path
setcap cap_net_raw+ep /bin/ping; \
else \
# Debian path
setcap cap_net_raw+ep /usr/bin/ping; \
fi
# Ensure necessary directories are writable
VOLUME ["/uploads", "/database"]
# Set container timezone and make entrypoint script executable
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone && \
chmod +x ./entrypoint.sh
# TODO: To improve security, consider dropping unnecessary capabilities instead of granting image all network capabilities of host. (Maybe `setcap cap_net_raw+p /usr/bin/ping`, etc.) Could also drop all and then grant only the capabilities that are explicitly needed. Some examples are commented out below...
# setcap cap_net_bind_service=+ep /usr/local/bin/node
# setcap cap_net_bind_service=+ep /usr/bin/ping
# setcap cap_net_bind_service=+ep /usr/bin/ping6
# setcap cap_net_bind_service=+ep /usr/bin/tracepath
# setcap cap_net_bind_service=+ep /usr/bin/clockdiff
# 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)
+11 -11
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.1.10"><img src="https://img.shields.io/badge/Latest_Stable_Release-3.1.10-blue" alt="Kener latest stable version: 3.1.10" /></a>
<a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.2.2"><img src="https://img.shields.io/badge/Latest_Stable_Release-3.2.2-blue" alt="Kener latest stable version: 3.2.2" /></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.1.10)</td>
<td>Latest stable release (aka 3.2.2)</td>
</tr>
<tr>
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.1.10" target="_blank"><code>3.1.10</code></a></td>
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.2.2" target="_blank"><code>3.2.2</code></a></td>
<td>Specific release version</td>
</tr>
<tr>
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.1" target="_blank"><code>3.1</code></a></td>
<td>Major-minor version tag pointing to the latest patch (3.1.10) release within that minor version (3.1.x)</td>
<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.2) 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.1.10) release within that major version (3.x.x)</td>
<td>Major version tag pointing to the latest stable (3.2.2) 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.1.10)</td>
<td>Latest stable release (aka 3.2.2)</td>
</tr>
<tr>
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.1.10-alpine" target="_blank"><code>3.1.10-alpine</code></a></td>
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.2.2-alpine" target="_blank"><code>3.2.2-alpine</code></a></td>
<td>Specific release version</td>
</tr>
<tr>
<td><a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=3.1-alpine" target="_blank"><code>3.1-alpine</code></a></td>
<td>Major-minor version tag pointing to the latest patch (3.1.10) release within that minor version (3.1.x)</td>
<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.2) 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.1.10) release within that major version (3.x.x)</td>
<td>Major version tag pointing to the latest stable (3.2.2) release within that major version (3.x.x)</td>
</tr>
</table>
+43 -5
View File
@@ -7,6 +7,45 @@ description: Changelogs for Kener
Here are the changelogs for Kener. Changelogs are only published when there are new features or breaking changes.
## v3.2.1
<picture>
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f680/512.webp" type="image/webp">
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f680/512.gif" alt="🚀" width="32" height="32">
</picture>
### Features
- **New Heartbeat Monitors**
- Added support for push-based monitoring via heartbeats
- Monitors systems that send periodic signals to confirm they're running
- Configurable thresholds for degraded and down states
- Secured with secret token authentication
- Accessible via simple HTTP API endpoints
- **Data Interpolation Improvements**
- Fixed data interpolation issues for more accurate uptime calculations
- Improved handling of timestamp boundaries
### Fixes & Improvements
- **UI Enhancements**
- Better mobile responsiveness for theme toggles and controls
- Fixed layout issues with bottom navigation buttons
- Improved timezone selector display
- **Data Processing**
- Fixed edge case issues with uptime calculations
- Improved handling of "No Data" status in summaries
- More accurate time range display across timezones
- **Documentation**
- Added comprehensive documentation for Heartbeat Monitors
- Updated API documentation with new endpoints
## v3.2.0
<picture>
@@ -59,7 +98,7 @@ If you're using custom evaluation functions in your monitors, you'll need to upd
```javascript
// Old format
(async function (statusCode, responseTime, responseDataBase64) {
;(async function (statusCode, responseTime, responseDataBase64) {
const resp = atob(responseDataBase64)
// Your logic here
})
@@ -67,7 +106,7 @@ If you're using custom evaluation functions in your monitors, you'll need to upd
```javascript
// New format
(async function (statusCode, responseTime, responseRaw, modules) {
;(async function (statusCode, responseTime, responseRaw, modules) {
// responseRaw is the direct response - no need to decode
// Access cheerio with modules.cheerio
// Your logic here
@@ -78,7 +117,7 @@ If you're using custom evaluation functions in your monitors, you'll need to upd
```javascript
// Old format
(async function (responseDataBase64) {
;(async function (responseDataBase64) {
let arrayOfPings = JSON.parse(atob(responseDataBase64))
// Your logic here
})
@@ -86,13 +125,12 @@ If you're using custom evaluation functions in your monitors, you'll need to upd
```javascript
// New format
(async function (arrayOfPings) {
;(async function (arrayOfPings) {
// arrayOfPings is directly available - no need to decode
// Your logic here
})
```
## v3.1.8
<picture>
+30 -75
View File
@@ -1,5 +1,5 @@
---
title: Kener Documentation
title: Kener Documentation | Open Source Status Page
description: Kener is a feature-rich and modern status page system built with SvelteKit and NodeJS. It is open-source and free to use.
---
@@ -36,86 +36,41 @@ description: Kener is a feature-rich and modern status page system built with Sv
</picture>
</div>
<div class="flex gap-2 kener-home-links">
<div class="flex-1 border rounded-md py-4 px-2 text-center">
<a href="https://kener.ing">Live Demo</a>
</div>
<div class="flex-1 border rounded-md py-4 px-2 text-center">
<a href="https://kener.ing/docs/quick-start">Quick Start</a>
</div>
<div class="flex-1 border rounded-md py-4 px-2 text-center">
<a href="https://github.com/rajnandan1/kener">Clone</a>
</div>
<div class="flex-1 border rounded-md py-4 px-2 text-center">
<a href="https://kener.ing/docs/deployment">Deploy</a>
</div>
<div class="flex-1 border rounded-md py-4 px-2 text-center">
<a href="https://kener.ing/docs/kener-apis">APIs</a>
</div>
<a href="https://kener.ing" class="flex-1 border rounded-md py-4 px-2 text-center">
Live Demo
</a>
<a href="https://kener.ing/docs/quick-start" class="flex-1 border rounded-md py-4 px-2 text-center">
Quick Start
</a>
<a href="https://github.com/rajnandan1/kener" class="flex-1 border rounded-md py-4 px-2 text-center">
Clone
</a>
<a href="https://kener.ing/docs/deployment" class="flex-1 border rounded-md py-4 px-2 text-center">
Deploy
</a>
<a href="https://kener.ing/docs/kener-apis" class="flex-1 border rounded-md py-4 px-2 text-center">
APIs
</a>
</div>
## What is Kener?
## Forget Boring Status Pages. Hello Kener!
Kener is status page system built with Sveltekit and NodeJS. It does not try to replace the Datadogs and Atlassian of the world. It tries to help some who wants to come up with a status page that looks nice and minimum overhead, in a modern way.
**Kener** isn't just another status page it's a beautifully crafted, lightning-fast monitoring system that makes your services look good even when they're down. Built with modern tech (**SvelteKit** + **NodeJS**), it delivers enterprise-grade reliability monitoring without the enterprise price tag (it's completely free!).
It comes with all the basic asks for a status page. It is open-source and free to use.
### Why Teams Are Switching to Kener
Kener name is derived from the word "Kene" which means "how is it going" in Assamese, then .ing because it was a
cheaply available domain.
- **📈 Up and running in minutes**, not days from Docker to deployment in less time than it takes to make coffee
- **🎭 Dark mode that actually looks good** because your 3 AM incident responses shouldn't burn your retinas
- **🧩 Ridiculously customizable** make it match your brand or go wild with a unique design
- **⚡ Blazing fast performance** server-rendered for speed that both users and search engines love
- **🌐 Works everywhere** auto-adjusts to visitors' time zones and looks great on any device
- **🔄 Real-time data** with gorgeous visualizations that make your uptime metrics pop
- **🛠️ Modern tooling** without the bloat everything you need, nothing you don't
## Quick Deployment
### Used by Companies That Value Simplicity and Power
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/template/spSvic?referralCode=1Pn7vs)
From indie developers to growing startups, teams are choosing Kener when they need reliability monitoring that doesn't require a dedicated DevOps team to maintain.
## Features
"_My team set up Kener in less than an hour, and our customers love the transparency it provides. The embedded widgets on our pricing page increased conversions by showing our reliability upfront._" A happy user
### Monitoring and Tracking
- Advanced application performance monitoring tools
- Types of Monitors: HTTP, TCP, DNS, ICMP etc
- Real-time network monitor software capabilities
- Polls HTTP endpoint or Push data to monitor using Rest APIs
- Handles Timezones for visitors
- Categorize Monitors into different Sections
- Cron-based scheduling for monitors. Minimum per minute
- Construct complex API Polls - Chain, Secrets etc
- Supports a Default Status for Monitors
- Supports base path for hosting in k8s
- Pre-built docker image for easy deployment
### Customization and Branding
- Customizable status page
- Badge generation for status and uptime of Monitors
- Support for custom domains
- Embed Monitor as an iframe or widget
- Light + Dark Theme
- Internationalization support
- Beautifully Crafted Status Page
### Incident Management
- Incident Management
- Incident Communication
- Comprehensive APIs for Incident Management
### User Experience and Design
- 100% Accessibility Score
- Easy installation and setup
- User-friendly interface
- Responsive design for various devices
- Auto SEO and Social Media ready
- Server Side Rendering
## Technologies used
- [SvelteKit](https://kit.svelte.dev/)
- [shadcn-svelte](https://www.shadcn-svelte.com/)
## Support Me
If you are using Kener and want to support me, you can do so by sponsoring me on GitHub or buying me a coffee.
[Sponsor Me Using Github](https://github.com/sponsors/rajnandan1)
[Buy Me a Coffee](https://www.buymeacoffee.com/rajnandan1)
## Ready to Transform Your Status Page?
+1
View File
@@ -77,6 +77,7 @@ Copy and update the translation file in the `locales` folder. The translation fi
"Standard": "Standard",
"Started %startedAt, lasted for %lastedFor": "Started %startedAt, lasted for %lastedFor",
"Started %startedAt, still ongoing": "Started %startedAt, still ongoing",
"Started %startedAt, will last for %lastedFor more": "Started %startedAt, will last for %lastedFor more",
"Starts %startedAt, will last for %lastedFor": "Starts %startedAt, will last for %lastedFor",
"Starts %startedAt": "Starts %startedAt",
"Status OK": "Status OK",
+112
View File
@@ -0,0 +1,112 @@
---
title: Heartbeat Monitors | Kener
description: Heartbeat monitors allow you to monitor systems using a "push" based approach
---
# Heartbeat Monitors
Heartbeat monitors allow you to monitor systems using a "push" based approach, where your services send signals (or "heartbeats") to Kener on a regular basis. If Kener doesn't receive a heartbeat within the configured time period, it will mark the system as degraded or down.
This is useful for monitoring services that:
- Run on a schedule
- Cannot be directly accessed for pull-based monitoring
- Need to report their own health status
## How It Works
1. You configure a heartbeat monitor in Kener
2. Kener provides a unique URL endpoint for your service to ping
3. Your service sends HTTP requests to this endpoint at regular intervals
4. If Kener doesn't receive a ping within the configured time window, it will mark the service as degraded or down
## Setting Up a Heartbeat Monitor
To create a heartbeat monitor:
1. Navigate to the Monitors page in the dashboard
2. Click "Add Monitor"
3. Select "Heartbeat" as the monitor type
4. Configure the following settings:
<div class="border rounded-md">
![Heartbeat Monitor Configuration](/documentation/m_hb.png)
</div>
### Configuration Options
| Option | Description |
| ------------------------ | -------------------------------------------------------------------------------------------- |
| Secret String | A unique secret that authenticates your heartbeat requests |
| Degraded After (Minutes) | Number of minutes after which the service is considered degraded if no heartbeat is received |
| Down After (Minutes) | Number of minutes after which the service is considered down if no heartbeat is received |
## Sending Heartbeats
Your service needs to send HTTP requests to the Kener heartbeat URL. Both GET and POST requests are supported.
### Endpoint Format
```
https://<your-kener-instance>/api/heartbeat/<tag>:<secretString>
```
Where:
- `<tag>` is the tag you configured for your monitor
- `<secretString>` is the secret you configured during setup
### Example
If your monitor has:
- Tag: `nightly-backup`
- Secret String: `super-secret-key`
Your service should send requests to:
```
https://<your-kener-instance>/api/heartbeat/nightly-backup:super-secret-key
```
You can use any HTTP client to send these requests, such as curl:
```bash
curl https://<your-kener-instance>/api/heartbeat/nightly-backup:super-secret-key
```
Or in a scheduled script:
```bash
#!/bin/bash
# Run your task here
./perform-backup.sh
# Send heartbeat to Kener
curl https://<your-kener-instance>/api/heartbeat/nightly-backup:super-secret-key
```
## Monitoring State
The monitor status works as follows:
- **UP**: Heartbeats are being received within the configured time windows
- **DEGRADED**: No heartbeat has been received for longer than the "Degraded After" time period
- **DOWN**: No heartbeat has been received for longer than the "Down After" time period
## Security Considerations
- Keep your secret string confidential, as anyone with knowledge of it can send heartbeats
- Consider using HTTPS for your Kener instance to encrypt heartbeat communications
- Rotate the secret string periodically by updating the monitor configuration
## Best Practices
- Set the "Degraded After" time slightly longer than your expected interval between heartbeats
- Set the "Down After" time based on your SLA requirements
- For critical systems, consider sending heartbeats more frequently than required
- Add logging in your service to track successful and failed heartbeat attempts
Heartbeat monitors provide a simple but effective way to ensure your scheduled tasks and services are running properly, without requiring external polling.
+21 -10
View File
@@ -109,15 +109,15 @@ To monitor the ping, you need to provide the IP address or the domain name. If t
To add a trigger to a monitor make sure you have created a trigger. You can read more about triggers [here](/docs/triggers).
- Click on the 🔔 icon on the top right corner of the monitor.
- Add details for either DOWN or DEGRADED.
- Failure Threshold(Required): The number of consecutive failures before the trigger is activated.
- Success Threshold(Required): The number of consecutive successes before the trigger is deactivated.
- Create Incident: Chose whether to create an incident or not when the trigger is activated. The incident will be created in Github. So make sure you have set up the Github token in the environment variables. The incident will be closed when the monitor is back to UP.
- Severity (Required): The severity of the incident. It can be `Critcal` or `Warning`.
- Custom Message (Required): Add your owner alert message.
- Choose Triggers: Choose the triggers you want to activate the trigger. You can choose multiple triggers.
- It will take upto 1 minute for the trigger to be activated.
- Click on the 🔔 icon on the top right corner of the monitor.
- Add details for either DOWN or DEGRADED.
- Failure Threshold(Required): The number of consecutive failures before the trigger is activated.
- Success Threshold(Required): The number of consecutive successes before the trigger is deactivated.
- Create Incident: Chose whether to create an incident or not when the trigger is activated. The incident will be created in Github. So make sure you have set up the Github token in the environment variables. The incident will be closed when the monitor is back to UP.
- Severity (Required): The severity of the incident. It can be `Critcal` or `Warning`.
- Custom Message (Required): Add your owner alert message.
- Choose Triggers: Choose the triggers you want to activate the trigger. You can choose multiple triggers.
- It will take upto 1 minute for the trigger to be activated.
---
@@ -135,6 +135,17 @@ You can activate the monitor by switching the toggle to on. Activation a monitor
<div class="note info">
Any changes in the a live monitor will take one minute to reflect.
Any changes in the a live monitor will take 2 minutes to reflect.
</div>
## Supported Monitors
- [API Monitors](/docs/monitors-api)
- [DNS Monitors](/docs/monitors-dns)
- [PING Monitors](/docs/monitors-ping)
- [TCP Monitors](/docs/monitors-tcp)
- [SQL Monitors](/docs/monitors-sql)
- [SSL Monitors](/docs/monitors-ssl)
- [HEARTBEAT Monitors](/docs/monitors-heartbeat)
- [GROUP Monitors](/docs/monitors-group)
+46 -35
View File
@@ -49,41 +49,7 @@
"link": "/docs/monitors",
"file": "/monitors.md"
},
{
"title": "API/Website Monitor",
"link": "/docs/monitors-api",
"file": "/monitors-api.md"
},
{
"title": "Ping Monitor",
"link": "/docs/monitors-ping",
"file": "/monitors-ping.md"
},
{
"title": "TCP Monitor",
"link": "/docs/monitors-tcp",
"file": "/monitors-tcp.md"
},
{
"title": "DNS Monitor",
"link": "/docs/monitors-dns",
"file": "/monitors-dns.md"
},
{
"title": "Group Monitor",
"link": "/docs/monitors-group",
"file": "/monitors-group.md"
},
{
"title": "SSL Monitor",
"link": "/docs/monitors-ssl",
"file": "/monitors-ssl.md"
},
{
"title": "SQL Monitor",
"link": "/docs/monitors-sql",
"file": "/monitors-sql.md"
},
{
"title": "Setup Triggers",
"link": "/docs/triggers",
@@ -142,6 +108,51 @@
}
]
},
{
"sectionTitle": "Monitors",
"children": [
{
"title": "API/Website Monitor",
"link": "/docs/monitors-api",
"file": "/monitors-api.md"
},
{
"title": "Ping Monitor",
"link": "/docs/monitors-ping",
"file": "/monitors-ping.md"
},
{
"title": "TCP Monitor",
"link": "/docs/monitors-tcp",
"file": "/monitors-tcp.md"
},
{
"title": "DNS Monitor",
"link": "/docs/monitors-dns",
"file": "/monitors-dns.md"
},
{
"title": "Group Monitor",
"link": "/docs/monitors-group",
"file": "/monitors-group.md"
},
{
"title": "SSL Monitor",
"link": "/docs/monitors-ssl",
"file": "/monitors-ssl.md"
},
{
"title": "SQL Monitor",
"link": "/docs/monitors-sql",
"file": "/monitors-sql.md"
},
{
"title": "Heartbeat Monitor",
"link": "/docs/monitors-heartbeat",
"file": "/monitors-heartbeat.md"
}
]
},
{
"sectionTitle": "API Reference",
"children": [
+18 -17
View File
@@ -9,28 +9,29 @@ const databaseType = databaseURLParts[0];
const databasePath = databaseURLParts[1];
const knexOb = {
migrations: {
directory: "./migrations"
},
seeds: {
directory: "./seeds"
}
migrations: {
directory: "./migrations",
},
seeds: {
directory: "./seeds",
},
databaseType,
};
if (databaseType === "sqlite") {
knexOb.client = "better-sqlite3";
knexOb.connection = {
filename: databasePath
};
knexOb.useNullAsDefault = true;
knexOb.client = "better-sqlite3";
knexOb.connection = {
filename: databasePath,
};
knexOb.useNullAsDefault = true;
} else if (databaseType === "postgresql") {
knexOb.client = "pg";
knexOb.connection = databaseURL;
knexOb.client = "pg";
knexOb.connection = databaseURL;
} else if (databaseType === "mysql") {
knexOb.client = "mysql2";
knexOb.connection = databaseURL;
knexOb.client = "mysql2";
knexOb.connection = databaseURL;
} else {
console.error("Invalid database type");
process.exit(1);
console.error("Invalid database type");
process.exit(1);
}
export default knexOb;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kener",
"version": "3.2.0",
"version": "3.2.4",
"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.",
+3 -2
View File
@@ -18,9 +18,10 @@ main {
text-decoration: none;
background-color: var(--bg-background);
}
.kener-home-links > div:hover {
.kener-home-links > a:hover {
transition: all 0.3s;
box-shadow: 0 0 8px 1.5px #3e9a4b;
border-color: #ed702d;
background-color: #ed702d;
}
.accm input:checked ~ div {
+38 -5
View File
@@ -41,7 +41,8 @@
.squares-pattern::after {
content: "";
position: absolute;
background-image: linear-gradient(#444cf7 1px, transparent 1px),
background-image:
linear-gradient(#444cf7 1px, transparent 1px),
linear-gradient(to right, #444cf7 1px, var(--background-kener) 1px);
-webkit-mask-image: linear-gradient(
180deg,
@@ -87,7 +88,8 @@
rgba(0, 0, 0, 0.03125) 80%,
rgba(0, 0, 0, 0) 100%
);
background-image: linear-gradient(#616fbf 1px, transparent 1px),
background-image:
linear-gradient(#616fbf 1px, transparent 1px),
linear-gradient(to right, #616fbf 1px, var(--background-kener) 1px);
}
@@ -476,7 +478,8 @@ textarea::placeholder {
}
.mesh {
background-image: linear-gradient(
background-image:
linear-gradient(
323deg,
rgba(255, 255, 255, 0.01) 0%,
rgba(255, 255, 255, 0.01) 16.667%,
@@ -607,7 +610,8 @@ textarea::placeholder {
}
.dark .mesh {
background-image: linear-gradient(
background-image:
linear-gradient(
158deg,
rgba(84, 84, 84, 0.03) 0%,
rgba(84, 84, 84, 0.03) 20%,
@@ -821,7 +825,8 @@ textarea::placeholder {
}
.button-77:active:not(:disabled):after {
background-image: linear-gradient(0deg, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.2)),
background-image:
linear-gradient(0deg, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.2)),
linear-gradient(92.83deg, #ff7426 0, #f93a13 100%);
bottom: 4px;
left: 4px;
@@ -872,3 +877,31 @@ textarea::placeholder {
transform: rotate(15deg);
}
}
.dots-animation::after {
content: ".";
animation: dots 1.5s steps(4) infinite;
}
@keyframes dots {
0% {
content: ".";
}
16% {
content: "..";
}
32% {
content: "...";
}
48% {
content: "....";
}
64% {
content: "...";
}
80% {
content: "..";
}
100% {
content: ".";
}
}
+248 -245
View File
@@ -1,288 +1,291 @@
// @ts-nocheck
import { base } from "$app/paths";
function siteDataExtractFromDb(data, obj) {
let requestedObject = { ...obj };
for (const key in requestedObject) {
if (Object.prototype.hasOwnProperty.call(requestedObject, key)) {
const element = data[key];
if (data[key]) {
requestedObject[key] = data[key];
}
}
}
//remove any keys that are still null or empty
for (const key in requestedObject) {
if (Object.prototype.hasOwnProperty.call(requestedObject, key)) {
const element = requestedObject[key];
if (element === null || element === "") {
delete requestedObject[key];
}
}
}
return requestedObject;
let requestedObject = { ...obj };
for (const key in requestedObject) {
if (Object.prototype.hasOwnProperty.call(requestedObject, key)) {
const element = data[key];
if (data[key]) {
requestedObject[key] = data[key];
}
}
}
//remove any keys that are still null or empty
for (const key in requestedObject) {
if (Object.prototype.hasOwnProperty.call(requestedObject, key)) {
const element = requestedObject[key];
if (element === null || element === "") {
delete requestedObject[key];
}
}
}
return requestedObject;
}
//a function to make an api call to /manage/api/ to store site data
function storeSiteData(data) {
return fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ action: "storeSiteData", data })
});
return fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ action: "storeSiteData", data }),
});
}
const allRecordTypes = {
A: 1,
NS: 2,
MD: 3,
MF: 4,
CNAME: 5,
SOA: 6,
MB: 7,
MG: 8,
MR: 9,
NULL: 10,
WKS: 11,
PTR: 12,
HINFO: 13,
MINFO: 14,
MX: 15,
TXT: 16,
RP: 17,
AFSDB: 18,
X25: 19,
ISDN: 20,
RT: 21,
NSAP: 22,
NSAP_PTR: 23,
SIG: 24,
KEY: 25,
PX: 26,
GPOS: 27,
AAAA: 28,
LOC: 29,
NXT: 30,
EID: 31,
NIMLOC: 32,
SRV: 33,
ATMA: 34,
NAPTR: 35,
KX: 36,
CERT: 37,
A6: 38,
DNAME: 39,
SINK: 40,
OPT: 41,
APL: 42,
DS: 43,
SSHFP: 44,
IPSECKEY: 45,
RRSIG: 46,
NSEC: 47,
DNSKEY: 48,
DHCID: 49,
NSEC3: 50,
NSEC3PARAM: 51,
TLSA: 52,
SMIMEA: 53,
HIP: 55,
NINFO: 56,
RKEY: 57,
TALINK: 58,
CDS: 59,
CDNSKEY: 60,
OPENPGPKEY: 61,
CSYNC: 62,
SPF: 99,
UINFO: 100,
UID: 101,
GID: 102,
UNSPEC: 103,
NID: 104,
L32: 105,
L64: 106,
LP: 107,
EUI48: 108,
EUI64: 109,
TKEY: 249,
TSIG: 250,
IXFR: 251,
AXFR: 252,
MAILB: 253,
MAILA: 254,
ANY: 255
A: 1,
NS: 2,
MD: 3,
MF: 4,
CNAME: 5,
SOA: 6,
MB: 7,
MG: 8,
MR: 9,
NULL: 10,
WKS: 11,
PTR: 12,
HINFO: 13,
MINFO: 14,
MX: 15,
TXT: 16,
RP: 17,
AFSDB: 18,
X25: 19,
ISDN: 20,
RT: 21,
NSAP: 22,
NSAP_PTR: 23,
SIG: 24,
KEY: 25,
PX: 26,
GPOS: 27,
AAAA: 28,
LOC: 29,
NXT: 30,
EID: 31,
NIMLOC: 32,
SRV: 33,
ATMA: 34,
NAPTR: 35,
KX: 36,
CERT: 37,
A6: 38,
DNAME: 39,
SINK: 40,
OPT: 41,
APL: 42,
DS: 43,
SSHFP: 44,
IPSECKEY: 45,
RRSIG: 46,
NSEC: 47,
DNSKEY: 48,
DHCID: 49,
NSEC3: 50,
NSEC3PARAM: 51,
TLSA: 52,
SMIMEA: 53,
HIP: 55,
NINFO: 56,
RKEY: 57,
TALINK: 58,
CDS: 59,
CDNSKEY: 60,
OPENPGPKEY: 61,
CSYNC: 62,
SPF: 99,
UINFO: 100,
UID: 101,
GID: 102,
UNSPEC: 103,
NID: 104,
L32: 105,
L64: 106,
LP: 107,
EUI48: 108,
EUI64: 109,
TKEY: 249,
TSIG: 250,
IXFR: 251,
AXFR: 252,
MAILB: 253,
MAILA: 254,
ANY: 255,
};
const ValidateIpAddress = function (input) {
// Check if input is a valid IPv4 address with an optional port
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (ipv4Regex.test(input)) {
return "IP4";
}
// Check if input is a valid IPv4 address with an optional port
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (ipv4Regex.test(input)) {
return "IP4";
}
// Improved IPv6 regex that better handles compressed notation
const ipv6Regex =
/^(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){0,7}:|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){6}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:)|(?:[0-9a-fA-F]{1,4}:){1,7}(?::|:[0-9a-fA-F]{1,4})|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?:(?::[0-9a-fA-F]{1,4}){1,6})|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:))$/;
if (ipv6Regex.test(input)) {
return "IP6";
}
// Improved IPv6 regex that better handles compressed notation
const ipv6Regex =
/^(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){0,7}:|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){6}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:)|(?:[0-9a-fA-F]{1,4}:){1,7}(?::|:[0-9a-fA-F]{1,4})|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?:(?::[0-9a-fA-F]{1,4}){1,6})|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:))$/;
if (ipv6Regex.test(input)) {
return "IP6";
}
// Check if input is a valid domain name with an optional port
const domainRegex = /^[a-zA-Z0-9]+([\-\.]{1}[a-zA-Z0-9]+)*\.[a-zA-Z]{2,}$/;
if (domainRegex.test(input)) {
return "DOMAIN";
}
// Check if input is a valid domain name with an optional port
const domainRegex = /^[a-zA-Z0-9]+([\-\.]{1}[a-zA-Z0-9]+)*\.[a-zA-Z]{2,}$/;
if (domainRegex.test(input)) {
return "DOMAIN";
}
// If none of the above conditions match, the input is invalid
return "Invalid";
// If none of the above conditions match, the input is invalid
return "Invalid";
};
function IsValidHost(domain) {
const regex = /^[a-zA-Z0-9]+([\-\.]{1}[a-zA-Z0-9]+)*\.[a-zA-Z]{2,}$/;
return regex.test(domain);
const regex = /^[a-zA-Z0-9]+([\-\.]{1}[a-zA-Z0-9]+)*\.[a-zA-Z]{2,}$/;
return regex.test(domain);
}
function IsValidNameServer(nameServer) {
//8.8.8.8 example
const regex = /^([0-9]{1,3}\.){3}[0-9]{1,3}$/;
return regex.test(nameServer);
//8.8.8.8 example
const regex = /^([0-9]{1,3}\.){3}[0-9]{1,3}$/;
return regex.test(nameServer);
}
const IsValidURL = function (url) {
return /^(http|https):\/\/[^ "]+$/.test(url);
return /^(http|https):\/\/[^ "]+$/.test(url);
};
function ValidateCronExpression(cronExp) {
// Check if expression is provided and is a string
if (!cronExp || typeof cronExp !== "string") {
return { isValid: false, message: "Cron expression must be a non-empty string" };
}
// Check if expression is provided and is a string
if (!cronExp || typeof cronExp !== "string") {
return { isValid: false, message: "Cron expression must be a non-empty string" };
}
// Split the expression into its components
const fields = cronExp.trim().split(/\s+/);
// Split the expression into its components
const fields = cronExp.trim().split(/\s+/);
// Standard cron should have 5 or 6 fields
// minute hour day-of-month month day-of-week [year]
if (fields.length < 5 || fields.length > 6) {
return {
isValid: false,
message: "Cron expression must have 5 or 6 fields"
};
}
// Standard cron should have 5 or 6 fields
// minute hour day-of-month month day-of-week [year]
if (fields.length < 5 || fields.length > 6) {
return {
isValid: false,
message: "Cron expression must have 5 or 6 fields",
};
}
// Define field constraints
const fieldConstraints = [
{ name: "minute", min: 0, max: 59 },
{ name: "hour", min: 0, max: 23 },
{ name: "day", min: 1, max: 31 },
{ name: "month", min: 1, max: 12 },
{ name: "weekday", min: 0, max: 6 },
{ name: "year", min: 1970, max: 2099 } // Optional field
];
// Define field constraints
const fieldConstraints = [
{ name: "minute", min: 0, max: 59 },
{ name: "hour", min: 0, max: 23 },
{ name: "day", min: 1, max: 31 },
{ name: "month", min: 1, max: 12 },
{ name: "weekday", min: 0, max: 6 },
{ name: "year", min: 1970, max: 2099 }, // Optional field
];
// Valid characters in cron expressions
const validChars = /^[\d/*,\-]+$/;
// Valid characters in cron expressions
const validChars = /^[\d/*,\-]+$/;
// Validate each field
for (let i = 0; i < fields.length; i++) {
const field = fields[i];
const constraint = fieldConstraints[i];
// Validate each field
for (let i = 0; i < fields.length; i++) {
const field = fields[i];
const constraint = fieldConstraints[i];
// Check for valid characters
if (!validChars.test(field)) {
return {
isValid: false,
message: `Invalid characters in ${constraint.name} field`
};
}
// Check for valid characters
if (!validChars.test(field)) {
return {
isValid: false,
message: `Invalid characters in ${constraint.name} field`,
};
}
// Handle special characters
if (field === "*") {
continue; // Asterisk is valid for all fields
}
// Handle special characters
if (field === "*") {
continue; // Asterisk is valid for all fields
}
// Handle lists (comma-separated values)
if (field.includes(",")) {
const values = field.split(",");
for (const value of values) {
if (!isValidRange(value, constraint.min, constraint.max)) {
return {
isValid: false,
message: `Invalid value in ${constraint.name} field: ${value}`
};
}
}
continue;
}
// Handle lists (comma-separated values)
if (field.includes(",")) {
const values = field.split(",");
for (const value of values) {
if (!isValidRange(value, constraint.min, constraint.max)) {
return {
isValid: false,
message: `Invalid value in ${constraint.name} field: ${value}`,
};
}
}
continue;
}
// Handle ranges (with hyphens)
if (field.includes("-")) {
const [start, end] = field.split("-").map(Number);
if (
start == null ||
end == null ||
start < constraint.min ||
end > constraint.max ||
start > end
) {
return {
isValid: false,
message: `Invalid range in ${constraint.name} field: ${field}`
};
}
continue;
}
// Handle ranges (with hyphens)
if (field.includes("-")) {
const [start, end] = field.split("-").map(Number);
if (start == null || end == null || start < constraint.min || end > constraint.max || start > end) {
return {
isValid: false,
message: `Invalid range in ${constraint.name} field: ${field}`,
};
}
continue;
}
// Handle steps (with forward slash)
if (field.includes("/")) {
const [range, step] = field.split("/");
if (range !== "*" && !isValidRange(range, constraint.min, constraint.max)) {
return {
isValid: false,
message: `Invalid range in ${constraint.name} field: ${range}`
};
}
if (!isValidRange(step, 1, constraint.max)) {
return {
isValid: false,
message: `Invalid step value in ${constraint.name} field: ${step}`
};
}
continue;
}
// Handle steps (with forward slash)
if (field.includes("/")) {
const [range, step] = field.split("/");
if (range !== "*" && !isValidRange(range, constraint.min, constraint.max)) {
return {
isValid: false,
message: `Invalid range in ${constraint.name} field: ${range}`,
};
}
if (!isValidRange(step, 1, constraint.max)) {
return {
isValid: false,
message: `Invalid step value in ${constraint.name} field: ${step}`,
};
}
continue;
}
// Handle plain numbers
if (!isValidRange(field, constraint.min, constraint.max)) {
return {
isValid: false,
message: `Invalid value in ${constraint.name} field: ${field}`
};
}
}
// Handle plain numbers
if (!isValidRange(field, constraint.min, constraint.max)) {
return {
isValid: false,
message: `Invalid value in ${constraint.name} field: ${field}`,
};
}
}
return { isValid: true, message: "Valid cron expression" };
return { isValid: true, message: "Valid cron expression" };
}
function isValidRange(value, min, max) {
const num = Number(value);
return !isNaN(num) && num >= min && num <= max;
const num = Number(value);
return !isNaN(num) && num >= min && num <= max;
}
function SortMonitor(monitorSort, resp) {
let monitors = [];
if (!!monitorSort && monitorSort.length > 0) {
monitors = monitorSort.map((id) => resp.find((m) => m.id == id)).filter((m) => !!m);
//append any new monitors
monitors = [...monitors, ...resp.filter((m) => !monitorSort.includes(m.id))];
} else {
monitors = resp;
}
return monitors;
let monitors = [];
if (!!monitorSort && monitorSort.length > 0) {
monitors = monitorSort.map((id) => resp.find((m) => m.id == id)).filter((m) => !!m);
//append any new monitors
monitors = [...monitors, ...resp.filter((m) => !monitorSort.includes(m.id))];
} else {
monitors = resp;
}
return monitors;
}
//js function to generate 32 character random string
function RandomString(length) {
const chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
let result = "";
for (let i = length; i > 0; --i) result += chars[Math.floor(Math.random() * chars.length)];
return result;
}
export {
siteDataExtractFromDb,
storeSiteData,
allRecordTypes,
ValidateIpAddress,
IsValidHost,
IsValidNameServer,
IsValidURL,
ValidateCronExpression,
SortMonitor
siteDataExtractFromDb,
storeSiteData,
allRecordTypes,
ValidateIpAddress,
IsValidHost,
IsValidNameServer,
IsValidURL,
ValidateCronExpression,
SortMonitor,
RandomString,
};
+74 -36
View File
@@ -1,16 +1,16 @@
<script>
import { formatDistanceToNow, formatDistance } from "date-fns";
import { Settings } from "lucide-svelte";
import { Settings, ArrowRight } from "lucide-svelte";
import * as Accordion from "$lib/components/ui/accordion";
import { l, f, fd, fdn } from "$lib/i18n/client";
import { base } from "$app/paths";
import { Button } from "$lib/components/ui/button";
import { Tooltip } from "bits-ui";
import GMI from "$lib/components/gmi.svelte";
import { page } from "$app/stores";
export let incident;
export let index;
export let lang;
export let selectedLang = "en";
let startTime = new Date(incident.start_date_time * 1000);
let endTime = new Date();
let nowTime = new Date();
@@ -18,13 +18,17 @@
endTime = new Date(incident.end_date_time * 1000);
}
let incidentType = incident.incident_type;
const lastedFor = fd(startTime, endTime, selectedLang);
const startedAt = fdn(startTime, selectedLang);
const lastedFor = fd(startTime, endTime, $page.data.selectedLang);
const remainingTime = fd(nowTime, endTime, $page.data.selectedLang);
const startedAt = fdn(startTime, $page.data.selectedLang);
let isFuture = false;
//is future incident
let incidentTimeStatus = "";
if (nowTime < startTime) {
isFuture = true;
incidentTimeStatus = "YET_TO_START";
} else if (nowTime >= startTime && nowTime <= endTime) {
incidentTimeStatus = "ONGOING";
} else if (nowTime > endTime) {
incidentTimeStatus = "COMPLETED";
}
let accordionValue = "incident-0";
@@ -37,30 +41,36 @@
let incidentDateSummary = "";
let maintenanceBadge = "";
let maintenanceBadgeColor = "";
if (!isFuture && incident.state != "RESOLVED") {
incidentDateSummary = l(lang, "Started %startedAt, still ongoing", {
if (incidentTimeStatus == "YET_TO_START") {
incidentDateSummary = l($page.data.lang, "Starts %startedAt", { startedAt });
if (incidentType === "MAINTENANCE") {
incidentDateSummary = l($page.data.lang, "Starts %startedAt, will last for %lastedFor", {
startedAt,
lastedFor
});
maintenanceBadge = "Upcoming Maintenance";
maintenanceBadgeColor = "text-upcoming-maintenance";
}
} else if (incidentTimeStatus == "ONGOING") {
incidentDateSummary = l($page.data.lang, "Started %startedAt, still ongoing", {
startedAt
});
maintenanceBadge = "Maintenance in Progress";
maintenanceBadgeColor = "text-maintenance-in-progress";
} else if (!isFuture && incident.state == "RESOLVED") {
incidentDateSummary = l(lang, "Started %startedAt, lasted for %lastedFor", {
if (incidentType === "MAINTENANCE") {
incidentDateSummary = l($page.data.lang, "Started %startedAt, will last for %lastedFor more", {
startedAt,
lastedFor: remainingTime
});
maintenanceBadge = "Maintenance in Progress";
maintenanceBadgeColor = "text-maintenance-in-progress";
}
} else if (incidentTimeStatus == "COMPLETED") {
incidentDateSummary = l($page.data.lang, "Started %startedAt, lasted for %lastedFor", {
startedAt,
lastedFor
});
maintenanceBadge = "Maintenance Completed";
maintenanceBadgeColor = "text-maintenance-completed";
} else if (isFuture && incident.state != "RESOLVED") {
incidentDateSummary = l(lang, "Starts %startedAt", { startedAt });
maintenanceBadge = "Upcoming Maintenance";
maintenanceBadgeColor = "text-upcoming-maintenance";
} else if (isFuture && incident.state == "RESOLVED") {
incidentDateSummary = l(lang, "Starts %startedAt, will last for %lastedFor", {
startedAt,
lastedFor
});
maintenanceBadge = "Upcoming Maintenance";
maintenanceBadgeColor = "text-upcoming-maintenance";
}
</script>
@@ -73,11 +83,11 @@
<p class="flex gap-x-2 text-xs font-semibold">
{#if incidentType == "INCIDENT"}
<span class="badge-{incident.state}">
{l(lang, incident.state)}
{l($page.data.lang, incident.state)}
</span>
{:else if incidentType == "MAINTENANCE"}
<span class="{maintenanceBadgeColor} ">
{l(lang, maintenanceBadge)}
{l($page.data.lang, maintenanceBadge)}
</span>
{/if}
{#if $page.data.isLoggedIn}
@@ -91,20 +101,48 @@
{/if}
</p>
<p class="scroll-m-20 text-lg font-medium tracking-tight">
<p class="font-medium">
{incident.title}
</p>
{#if !!incidentDateSummary}
<p class="scroll-m-20 text-sm font-medium tracking-wide text-muted-foreground">
{incidentDateSummary}
</p>
<Tooltip.Root openDelay={100} side="bottom" align="end">
<Tooltip.Trigger
class=" text-ellipsis whitespace-nowrap text-xs font-medium tracking-normal text-muted-foreground"
>
{incidentDateSummary}
</Tooltip.Trigger>
<Tooltip.Content class=" z-20 mt-11" side="bottom" align="end">
<div
class="items-center justify-center rounded border bg-primary px-1.5 py-1 text-xs font-medium text-primary-foreground shadow-popover"
>
{f(
new Date(incident.start_date_time * 1000),
"MMMM do yyyy, h:mm:ss a",
$page.data.selectedLang,
$page.data.localTz
)}
{#if incident.end_date_time}
<ArrowRight class="mx-1 -mt-0.5 inline h-3 w-3" />
{f(
new Date(incident.end_date_time * 1000),
"MMMM do yyyy, h:mm:ss a",
$page.data.selectedLang,
$page.data.localTz
)}
{:else}
<ArrowRight class="mx-1 -mt-0.5 inline h-3 w-3" />
<span class="dots-animation inline-block w-6 text-left"></span>
{/if}
</div>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
</Accordion.Trigger>
<Accordion.Content>
<div class="px-4 pt-2">
{#if incident.monitors.length > 0}
<div class="flex gap-2">
<div class="flex flex-wrap gap-2">
{#each incident.monitors as monitor}
<div class="tag-affected-text flex gap-x-2 rounded-md bg-secondary px-1 py-1 pr-2">
<div
@@ -123,7 +161,7 @@
</div>
{/if}
<p class="my-3 text-xs font-semibold uppercase text-muted-foreground">
{l(lang, "Updates")}
{l($page.data.lang, "Updates")}
</p>
{#if incident.comments.length > 0}
{#if incidentType == "INCIDENT"}
@@ -133,14 +171,14 @@
<div
class="absolute top-0 w-28 -translate-x-32 rounded border bg-secondary px-1.5 py-1 text-center text-xs font-semibold"
>
{l(lang, comment.state)}
{l($page.data.lang, comment.state)}
</div>
<time class=" mb-1 text-sm font-medium leading-none text-muted-foreground">
{f(
new Date(comment.commented_at * 1000),
"MMMM do yyyy, h:mm:ss a",
selectedLang,
$page.data.selectedLang,
$page.data.localTz
)}
</time>
@@ -159,7 +197,7 @@
{f(
new Date(comment.commented_at * 1000),
"MMMM do yyyy, h:mm:ss a",
selectedLang,
$page.data.selectedLang,
$page.data.localTz
)}
</time>
@@ -173,7 +211,7 @@
{/if}
{:else}
<p class="text-sm font-medium">
{l(lang, "No Updates Yet")}
{l($page.data.lang, "No Updates Yet")}
</p>
{/if}
</div>
+83 -2
View File
@@ -1,6 +1,6 @@
<script>
import { Button } from "$lib/components/ui/button";
import { Plus, X, Loader } from "lucide-svelte";
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";
@@ -9,6 +9,7 @@
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 {
allRecordTypes,
ValidateIpAddress,
@@ -84,6 +85,10 @@
let invalidFormMessage = "";
function copyToClipboard(t) {
navigator.clipboard.writeText(t);
}
async function isValidEval(ev) {
if (ev.endsWith(";")) {
invalidFormMessage = "Eval should not end with semicolon";
@@ -354,8 +359,29 @@
}
newMonitor.type_data = JSON.stringify(newMonitor.sqlConfig);
}
} else if (newMonitor.monitor_type === "HEARTBEAT") {
//newMonitor.heartbeatConfig.degradedRemainingMinutes should be a number and greater than equal to 1
if (
!!!newMonitor.heartbeatConfig.degradedRemainingMinutes ||
isNaN(newMonitor.heartbeatConfig.degradedRemainingMinutes) ||
newMonitor.heartbeatConfig.degradedRemainingMinutes < 1
) {
invalidFormMessage = "Degraded or grace Period should at least be 1 minute";
return;
}
//newMonitor.heartbeatConfig.downRemainingMinutes should be a number and greater than degradedRemainingMinutes
if (
!!!newMonitor.heartbeatConfig.downRemainingMinutes ||
isNaN(newMonitor.heartbeatConfig.downRemainingMinutes) ||
newMonitor.heartbeatConfig.downRemainingMinutes <= newMonitor.heartbeatConfig.degradedRemainingMinutes
) {
invalidFormMessage = "Down or impacted Period should be greater than Degraded or grace Period";
return;
}
newMonitor.type_data = JSON.stringify(newMonitor.heartbeatConfig);
}
formState = "loading";
try {
@@ -607,6 +633,7 @@
<Select.Item value="GROUP" label="GROUP" class="text-sm font-medium">GROUP</Select.Item>
<Select.Item value="SSL" label="SSL" class="text-sm font-medium">SSL</Select.Item>
<Select.Item value="SQL" label="SQL" class="text-sm font-medium">SQL</Select.Item>
<Select.Item value="HEARTBEAT" label="HEARTBEAT" class="text-sm font-medium">HEARTBEAT</Select.Item>
</Select.Group>
</Select.Content>
</Select.Root>
@@ -1129,6 +1156,60 @@
</span>
</div>
</div>
{:else if newMonitor.monitor_type == "HEARTBEAT"}
<div>
<div class="relative mt-4 justify-start gap-x-4">
<p class="truncate rounded-md border bg-popover p-2 pr-8 text-sm font-medium">
Heart beat url: <span class="text-muted-foreground">
{$page.data.siteData.siteURL +
`${base}/api/heartbeat/${newMonitor.tag}:${newMonitor.heartbeatConfig.secretString}`}</span
>
</p>
<Button
class="copybtn absolute right-2 top-2 flex h-6 w-6 justify-center p-1"
variant="ghost"
size="icon"
on:click={() =>
copyToClipboard(
$page.data.siteData.siteURL +
`${base}/api/heartbeat/${newMonitor.tag}:${newMonitor.heartbeatConfig.secretString}`
)}
>
<Check class="check-btn absolute h-4 w-4 text-green-500" />
<Clipboard class="copy-btn absolute h-4 w-4" />
</Button>
</div>
<div class="mt-2 flex flex-row justify-start gap-x-4">
<div>
<Label for="degradedRemainingMinutes">Degraded or grace Period (in minutes)</Label>
<Input
placeholder="in minutes"
class="w-full"
type="number"
bind:value={newMonitor.heartbeatConfig.degradedRemainingMinutes}
id="degradedRemainingMinutes"
/>
</div>
<div>
<Label for="downRemainingMinutes">Down or impacted Period (in minutes)</Label>
<Input
placeholder="in minutes"
type="number"
bind:value={newMonitor.heartbeatConfig.downRemainingMinutes}
id="downRemainingMinutes"
/>
</div>
</div>
<div class="col-span-1">
<span class="text-xs text-muted-foreground"
>Refer to the
<a target="_blank" class="font-medium text-primary" href="https://kener.ing/docs/monitors-heartbeat">
documentation
</a> for more details
</span>
</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">
+8 -1
View File
@@ -8,7 +8,7 @@
import { onMount } from "svelte";
import * as Card from "$lib/components/ui/card";
import * as Select from "$lib/components/ui/select";
import { storeSiteData, SortMonitor } from "$lib/clientTools.js";
import { storeSiteData, SortMonitor, RandomString } from "$lib/clientTools.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { dndzone } from "svelte-dnd-action";
import { flip } from "svelte/animate";
@@ -104,6 +104,11 @@
query: "SELECT 1",
timeout: 5000,
dbType: "pg" //mysql2
},
heartbeatConfig: {
degradedRemainingMinutes: 1,
downRemainingMinutes: 2,
secretString: RandomString(32)
}
};
}
@@ -140,6 +145,8 @@
newMonitor.sslConfig = JSON.parse(newMonitor.type_data);
} else if (newMonitor.monitor_type == "SQL") {
newMonitor.sqlConfig = JSON.parse(newMonitor.type_data);
} else if (newMonitor.monitor_type == "HEARTBEAT") {
newMonitor.heartbeatConfig = JSON.parse(newMonitor.type_data);
}
showAddMonitor = true;
}
+9 -1
View File
@@ -149,6 +149,14 @@
}
}
}
if (newTrigger.trigger_meta.has_webhook_body) {
if (newTrigger.trigger_meta.webhook_body == "") {
invalidFormMessage = "Webhook Body is required";
formState = "idle";
return;
}
}
}
//newTrigger.name present not empty
if (newTrigger.name == "") {
@@ -423,7 +431,7 @@
}}
/>
<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"
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>
+16 -10
View File
@@ -53,10 +53,13 @@
}
function getToday(startTs, incidentIDs) {
let endTs = Math.min(startTs + 86400, monitor.pageData.maxDateTodayTimestamp);
axios
.post(`${base}/api/today`, {
monitor: monitor,
localTz: localTz,
endTs: endTs,
startTs: startTs,
incidentIDs: incidentIDs
})
@@ -165,6 +168,7 @@
let dateFetchedFor = "";
let dayUptime = "NA";
let loadingDayData = false;
function dailyDataGetter(e, bar, incidentObj) {
if (embed) {
return;
@@ -268,16 +272,18 @@
{#if rollerLoading}
<Loader class=" mt-0.5 inline h-3.5 w-3.5 animate-spin text-muted-foreground" />
{/if}
<NumberFlow
class="border-r pr-2 text-xs font-semibold"
value={uptimesRollers[rolledAt].value}
format={{
notation: "standard",
minimumFractionDigits: 4,
maximumFractionDigits: 4
}}
suffix="%"
/>
{#if !isNaN(uptimesRollers[rolledAt].value)}
<NumberFlow
class="border-r pr-2 text-xs font-semibold"
value={uptimesRollers[rolledAt].value}
format={{
notation: "standard",
minimumFractionDigits: 4,
maximumFractionDigits: 4
}}
suffix="%"
/>
{/if}
<div class="truncate text-xs font-semibold text-{monitor.pageData.summaryColorClass}">
{monitor.pageData.summaryStatus}
</div>
+18 -16
View File
@@ -40,22 +40,24 @@
</a>
{/each}
</nav>
<DropdownMenu.Root class="">
<DropdownMenu.Trigger class="mr-2 flex md:hidden">
<Button variant="outline" size="sm">
<Menu size={14} />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{#each data.site.nav as navItem}
<DropdownMenu.Group>
<DropdownMenu.Item>
<a rel="external" href={navItem.url}> {navItem.name} </a>
</DropdownMenu.Item>
</DropdownMenu.Group>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
<div class="flex md:hidden">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="outline" size="sm">
<Menu size={14} />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{#each data.site.nav as navItem}
<DropdownMenu.Group>
<DropdownMenu.Item>
<a rel="external" href={navItem.url}> {navItem.name} </a>
</DropdownMenu.Item>
</DropdownMenu.Group>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{/if}
</div>
</div>
+1
View File
@@ -48,6 +48,7 @@
"Standard": "Standard",
"Started %startedAt, lasted for %lastedFor": "Begann vor etwa %startedAt und dauerte etwa %lastedFor",
"Started %startedAt, still ongoing": "Begann vor etwa %startedAt und dauert noch an",
"Started %startedAt, will last for %lastedFor more": "Begann vor etwa %startedAt und wird noch etwa %lastedFor dauern",
"Starts %startedAt, will last for %lastedFor": "Beginnt in %startedAt und wird etwa %lastedFor dauern",
"Starts %startedAt": "Beginnt in %startedAt",
"Status OK": "Status OK",
+1
View File
@@ -48,6 +48,7 @@
"Standard": "Standard",
"Started %startedAt, lasted for %lastedFor": "Startede for cirka %startedAt siden, varede i cirka %lastedFor",
"Started %startedAt, still ongoing": "Startede for cirka %startedAt siden, stadig igangværende",
"Started %startedAt, will last for %lastedFor more": "Startede for cirka %startedAt siden, vil vare i cirka %lastedFor mere",
"Starts %startedAt, will last for %lastedFor": "Starter om %startedAt, vil vare i cirka %lastedFor",
"Starts %startedAt": "Starter om %startedAt",
"Status OK": "Status OK",
+1
View File
@@ -48,6 +48,7 @@
"Standard": "Standard",
"Started %startedAt, lasted for %lastedFor": "Started %startedAt, lasted for %lastedFor",
"Started %startedAt, still ongoing": "Started %startedAt, still ongoing",
"Started %startedAt, will last for %lastedFor more": "Started %startedAt, will last for %lastedFor more",
"Starts %startedAt, will last for %lastedFor": "Starts %startedAt, will last for %lastedFor",
"Starts %startedAt": "Starts %startedAt",
"Status OK": "Status OK",
+1
View File
@@ -48,6 +48,7 @@
"Standard": "Standard",
"Started %startedAt, lasted for %lastedFor": "Commencé il y a environ %startedAt, a duré environ %lastedFor",
"Started %startedAt, still ongoing": "Commencé il y a environ %startedAt, toujours en cours",
"Started %startedAt, will last for %lastedFor more": "Commencé il y a environ %startedAt, durera environ %lastedFor de plus",
"Starts %startedAt, will last for %lastedFor": "Commence dans %startedAt, durera environ %lastedFor",
"Starts %startedAt": "Commence dans %startedAt",
"Status OK": "Statut OK",
+1
View File
@@ -48,6 +48,7 @@
"Standard": "मानक",
"Started %startedAt, lasted for %lastedFor": "%startedAt शुरू हुआ, %lastedFor तक चला",
"Started %startedAt, still ongoing": "%startedAt शुरू हुआ, अभी भी जारी है",
"Started %startedAt, will last for %lastedFor more": "%startedAt शुरू होगा, %lastedFor और चलेगा",
"Starts %startedAt, will last for %lastedFor": "%startedAt में शुरू होगा, %lastedFor तक चलेगा",
"Starts %startedAt": "%startedAt में शुरू होगा",
"Status OK": "स्थिति ठीक है",
+1
View File
@@ -48,6 +48,7 @@
"Standard": "標準",
"Started %startedAt, lasted for %lastedFor": "%startedAt 前に開始し、約 %lastedFor 続きました",
"Started %startedAt, still ongoing": "%startedAt 前に開始し、まだ進行中です",
"Started %startedAt, will last for %lastedFor more": "%startedAt 前に開始し、さらに %lastedFor 続きます",
"Starts %startedAt, will last for %lastedFor": "%startedAt 後に開始し、約 %lastedFor 続きます",
"Starts %startedAt": "%startedAt 後に開始",
"Status OK": "ステータス正常",
+1
View File
@@ -48,6 +48,7 @@
"Standard": "기본",
"Started %startedAt, lasted for %lastedFor": "%startedAt 전에 시작되었으며, %lastedFor 동안 지속되었습니다",
"Started %startedAt, still ongoing": "%startedAt 전에 시작되었으며, 아직 진행중입니다",
"Started %startedAt, will last for %lastedFor more": "%startedAt 전에 시작되었으며, %lastedFor 더 지속될 예정입니다",
"Starts %startedAt, will last for %lastedFor": "%startedAt 전에 시작되었으며, %lastedFor 까지 지속될 예정입니다",
"Starts %startedAt": "%startedAt 에 시작되었습니다",
"Status OK": "양호",
+1
View File
@@ -48,6 +48,7 @@
"Standard": "Standard",
"Started %startedAt, lasted for %lastedFor": "Startet %startedAt, varte i %lastedFor",
"Started %startedAt, still ongoing": "Startet %startedAt, fortsatt pågår",
"Started %startedAt, will last for %lastedFor more": "Startet %startedAt, vil vare i %lastedFor til",
"Starts %startedAt, will last for %lastedFor": "Starter %startedAt, vil vare i %lastedFor",
"Starts %startedAt": "Starter %startedAt",
"Status OK": "Status OK",
+1
View File
@@ -48,6 +48,7 @@
"Standard": "Standaard",
"Started %startedAt, lasted for %lastedFor": "Begonnen ongeveer %startedAt geleden, duurde ongeveer %lastedFor",
"Started %startedAt, still ongoing": "Begonnen ongeveer %startedAt geleden, nog steeds bezig",
"Started %startedAt, will last for %lastedFor more": "Begint %startedAt, zal nog ongeveer %lastedFor duren",
"Starts %startedAt, will last for %lastedFor": "Begint over %startedAt, zal ongeveer %lastedFor duren",
"Starts %startedAt": "Begint over %startedAt",
"Status OK": "Status OK",
+1
View File
@@ -48,6 +48,7 @@
"Standard": "Padrão",
"Started %startedAt, lasted for %lastedFor": "Ocorreu faz %startedAt e durou %lastedFor",
"Started %startedAt, still ongoing": "Iniciou faz %startedAt, ainda em andamento",
"Started %startedAt, will last for %lastedFor more": "Iniciará em %startedAt, durará mais %lastedFor",
"Starts %startedAt, will last for %lastedFor": "Terá início em %startedAt, com duração de %lastedFor",
"Starts %startedAt": "Inicia em %startedAt",
"Status OK": "Status OK",
+1
View File
@@ -48,6 +48,7 @@
"Standard": "Стандартная",
"Started %startedAt, lasted for %lastedFor": "Началось около %startedAt назад, длилось около %lastedFor",
"Started %startedAt, still ongoing": "Началось около %startedAt назад, все еще продолжается",
"Started %startedAt, will last for %lastedFor more": "Начнется около %startedAt, продлится еще около %lastedFor",
"Starts %startedAt, will last for %lastedFor": "Начнется через %startedAt, продлится около %lastedFor",
"Starts %startedAt": "Начнется через %startedAt",
"Status OK": "Статус ОК",
+1
View File
@@ -48,6 +48,7 @@
"Standard": "Standart",
"Started %startedAt, lasted for %lastedFor": "%startedAt başladı, %lastedFor sürdü",
"Started %startedAt, still ongoing": "%startedAt başladı, devam ediyor",
"Started %startedAt, will last for %lastedFor more": "%startedAt başladı, %lastedFor daha sürecek",
"Starts %startedAt, will last for %lastedFor": "%startedAt başlıyor, %lastedFor sürecek",
"Starts %startedAt": "%startedAt başlıyor",
"Status OK": "Durum UYGUN",
+1
View File
@@ -48,6 +48,7 @@
"Standard": "Tiêu chuẩn",
"Started %startedAt, lasted for %lastedFor": "Bắt đầu khoảng %startedAt trước, kéo dài khoảng %lastedFor",
"Started %startedAt, still ongoing": "Bắt đầu khoảng %startedAt trước, vẫn đang diễn ra",
"Started %startedAt, will last for %lastedFor more": "Bắt đầu khoảng %startedAt trước, sẽ kéo dài thêm khoảng %lastedFor",
"Starts %startedAt, will last for %lastedFor": "Bắt đầu trong %startedAt, sẽ kéo dài khoảng %lastedFor",
"Starts %startedAt": "Bắt đầu trong %startedAt",
"Status OK": "Trạng thái OK",
+1
View File
@@ -48,6 +48,7 @@
"Standard": "标准",
"Started %startedAt, lasted for %lastedFor": "大约 %startedAt 前开始,持续了约 %lastedFor",
"Started %startedAt, still ongoing": "大约 %startedAt 前开始,目前仍在进行中",
"Started %startedAt, will last for %lastedFor more": "大约 %startedAt 前开始,将持续约 %lastedFor",
"Starts %startedAt, will last for %lastedFor": "%startedAt 后开始,将持续约 %lastedFor",
"Starts %startedAt": "%startedAt 后开始",
"Status OK": "状态正常",
+4
View File
@@ -102,6 +102,8 @@ const TIMEOUT = "timeout";
const ERROR = "error";
const MANUAL = "manual";
const WEBHOOK = "webhook";
const DEFAULT_STATUS = "default_status";
const SIGNAL = "signal";
export {
MONITOR,
@@ -119,4 +121,6 @@ export {
MANUAL,
NO_DATA,
WEBHOOK,
DEFAULT_STATUS,
SIGNAL,
};
+37 -2
View File
@@ -17,7 +17,8 @@ import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import crypto from "crypto";
import { format, subMonths, addMonths, startOfMonth } from "date-fns";
import { UP, DOWN, DEGRADED, NO_DATA } from "../constants.js";
import { UP, DOWN, DEGRADED, NO_DATA, REALTIME, SIGNAL } from "../constants.js";
import { GetMinuteStartNowTimestampUTC, GetNowTimestampUTC } from "../tool.js";
const saltRounds = 10;
const DUMMY_SECRET = "DUMMY_SECRET";
@@ -298,6 +299,40 @@ export const VerifyPassword = async (plainTextPassword, hashedPassword) => {
}
};
export const GetLatestMonitoringData = async (monitor_tag) => {
return await db.getLatestMonitoringData(monitor_tag);
};
export const GetLastHeartbeat = async (monitor_tag) => {
return await db.getLastHeartbeat(monitor_tag);
};
export const RegisterHeartbeat = async (tag, secret) => {
let monitor = await db.getMonitorByTag(tag);
if (!monitor) {
return null;
}
let typeData = monitor.type_data;
if (!typeData) {
return null;
}
try {
let heartbeatConfig = JSON.parse(typeData);
let heartbeatSecret = heartbeatConfig.secretString;
if (heartbeatSecret === secret) {
return await db.insertMonitoringData({
monitor_tag: monitor.tag,
timestamp: GetMinuteStartNowTimestampUTC(GetNowTimestampUTC()),
status: UP,
latency: 0,
type: SIGNAL,
});
}
} catch (e) {
console.error("Error registering heartbeat:", e);
}
return null;
};
export const GenerateToken = async (data) => {
try {
const token = jwt.sign(data, process.env.KENER_SECRET_KEY || DUMMY_SECRET, {
@@ -467,7 +502,7 @@ export const GetDataGroupByDayAlternative = async (monitor_tag, start, end, time
let rawData = await db.getDataGroupByDayAlternative(monitor_tag, start, end);
let anchorStatus = await GetLastStatusBefore(monitor_tag, start);
rawData = InterpolateData(rawData, start, anchorStatus, end - 60);
rawData = InterpolateData(rawData, start, anchorStatus, end);
const groupedData = rawData.reduce((acc, row) => {
// Calculate day group considering timezone offset
+10 -4
View File
@@ -1,7 +1,7 @@
// @ts-nocheck
import axios from "axios";
import { Ping, ExtractIPv6HostAndPort, TCP } from "./ping.js";
import { UP, DOWN, DEGRADED, REALTIME, TIMEOUT, ERROR, MANUAL } from "./constants.js";
import { UP, DOWN, DEGRADED, REALTIME, TIMEOUT, ERROR, MANUAL, DEFAULT_STATUS } from "./constants.js";
import Service from "./services/service.js";
import { GetMinuteStartNowTimestampUTC, ReplaceAllOccurrences, GetRequiredSecrets, Wait } from "./tool.js";
@@ -105,6 +105,8 @@ const Minuter = async (monitor) => {
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);
@@ -117,17 +119,21 @@ const Minuter = async (monitor) => {
mergedData[startOfMinute] = {
status: monitor.default_status,
latency: 0,
type: "default_status",
type: DEFAULT_STATUS,
};
}
}
for (const timestamp in realTimeData) {
mergedData[timestamp] = realTimeData[timestamp];
if (!!realTimeData[timestamp] && !!realTimeData[timestamp].status) {
mergedData[timestamp] = realTimeData[timestamp];
}
}
for (const timestamp in manualData) {
mergedData[timestamp] = manualData[timestamp];
if (!!manualData[timestamp] && !!manualData[timestamp].status) {
mergedData[timestamp] = manualData[timestamp];
}
}
for (const timestamp in mergedData) {
+44 -16
View File
@@ -1,6 +1,6 @@
// @ts-nocheck
import { GetMinuteStartNowTimestampUTC } from "../tool.js";
import { MANUAL } from "../constants.js";
import { GetMinuteStartNowTimestampUTC, GetDbType } from "../tool.js";
import { MANUAL, SIGNAL } from "../constants.js";
import Knex from "knex";
class DbImpl {
@@ -40,6 +40,15 @@ class DbImpl {
.first();
}
async getLastHeartbeat(monitor_tag) {
return await this.knex("monitoring_data")
.where("monitor_tag", monitor_tag)
.where("type", SIGNAL)
.orderBy("timestamp", "desc")
.limit(1)
.first();
}
//given monitor_tag, start and end timestamp in utc seconds return total degraded, up, down, avg(latency), max(latency), min(latency)
async getAggregatedMonitoringData(monitor_tag, start, end) {
return await this.knex("monitoring_data")
@@ -449,20 +458,39 @@ class DbImpl {
}
async createIncident(data) {
const [incident] = await this.knex("incidents")
.insert({
title: data.title,
start_date_time: data.start_date_time,
end_date_time: data.end_date_time,
status: data.status,
state: data.state,
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
incident_type: data.incident_type,
incident_source: data.incident_source,
})
.returning("*");
return incident;
const dbType = GetDbType(); // sqlite, postgresql, mysql
// Common insert data
const insertData = {
title: data.title,
start_date_time: data.start_date_time,
end_date_time: data.end_date_time,
status: data.status,
state: data.state,
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
incident_type: data.incident_type,
incident_source: data.incident_source,
};
// PostgreSQL supports returning clause
if (dbType === "postgresql") {
const [incident] = await this.knex("incidents").insert(insertData).returning("*");
return incident;
}
// MySQL and SQLite need different approaches
else {
// Insert and get the ID
const result = await this.knex("incidents").insert(insertData);
// Different handling for MySQL vs SQLite
let id = result[0];
// Fetch the newly created record
const incident = await this.knex("incidents").where("id", id).first();
return incident;
}
}
//get incidents paginated
+89 -83
View File
@@ -1,93 +1,99 @@
// @ts-nocheck
class Discord {
url;
headers;
method;
siteData;
monitorData;
url;
headers;
method;
siteData;
monitorData;
constructor(url, siteData, monitorData) {
const kenerHeader = {
"Content-Type": "application/json",
"User-Agent": "Kener"
};
constructor(url, siteData, monitorData) {
const kenerHeader = {
"Content-Type": "application/json",
"User-Agent": "Kener",
};
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;
}
transformData(data) {
let siteURL = this.siteData.siteURL;
let logo =
this.siteData.siteURL +
(!!process.env.KENER_BASE_PATH ? process.env.KENER_BASE_PATH : "") +
this.siteData.logo;
transformData(data) {
let siteURL = this.siteData.siteURL;
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
}
]
};
}
//remove / if there at the end of the url
if (siteURL.endsWith("/")) {
siteURL = siteURL.slice(0, -1);
}
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;
}
}
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,
},
],
};
}
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;
}
}
}
export default Discord;
+77 -79
View File
@@ -3,92 +3,90 @@
import { GetRequiredSecrets, ReplaceAllOccurrences } from "../tool.js";
class Webhook {
url;
headers;
method;
siteData;
monitorData;
trigger_meta;
url;
headers;
method;
siteData;
monitorData;
trigger_meta;
constructor(trigger_meta, method, siteData, monitorData) {
const kenerHeader = {
"Content-Type": "application/json",
"User-Agent": "Kener/3.0.0"
};
let headers = trigger_meta.headers;
this.trigger_meta = trigger_meta;
this.url = trigger_meta.url;
this.headers = kenerHeader;
for (let i = 0; i < headers.length; i++) {
const header = headers[i];
this.headers[header.key] = header.value;
}
this.method = "POST";
this.siteData = siteData;
this.monitorData = monitorData;
constructor(trigger_meta, method, siteData, monitorData) {
const kenerHeader = {
"Content-Type": "application/json",
"User-Agent": "Kener/3.2.4",
};
let headers = trigger_meta.headers;
this.trigger_meta = trigger_meta;
this.url = trigger_meta.url;
this.headers = kenerHeader;
for (let i = 0; i < headers.length; i++) {
const header = headers[i];
this.headers[header.key] = header.value;
}
this.method = "POST";
this.siteData = siteData;
this.monitorData = monitorData;
let envSecrets = GetRequiredSecrets(
`${this.url} ${JSON.stringify(this.headers)} ${JSON.stringify(this.trigger_meta.webhook_body)}`
);
//replace secrets in url and headers
let envSecrets = GetRequiredSecrets(
`${this.url} ${JSON.stringify(this.headers)} ${JSON.stringify(this.trigger_meta.webhook_body)}`,
);
//replace secrets in url and headers
for (let i = 0; i < envSecrets.length; i++) {
const secret = envSecrets[i];
this.url = ReplaceAllOccurrences(this.url, secret.find, secret.replace);
this.headers = JSON.parse(
ReplaceAllOccurrences(JSON.stringify(this.headers), secret.find, secret.replace)
);
}
for (let i = 0; i < envSecrets.length; i++) {
const secret = envSecrets[i];
this.url = ReplaceAllOccurrences(this.url, secret.find, secret.replace);
this.headers = JSON.parse(ReplaceAllOccurrences(JSON.stringify(this.headers), secret.find, secret.replace));
}
if (!!this.trigger_meta.has_webhook_body && !!this.trigger_meta.webhook_body) {
envSecrets = GetRequiredSecrets(this.trigger_meta.webhook_body);
for (let i = 0; i < envSecrets.length; i++) {
const secret = envSecrets[i];
this.trigger_meta.webhook_body = ReplaceAllOccurrences(
this.trigger_meta.webhook_body,
secret.find,
secret.replace
);
}
}
}
if (!!this.trigger_meta.has_webhook_body && !!this.trigger_meta.webhook_body) {
envSecrets = GetRequiredSecrets(this.trigger_meta.webhook_body);
for (let i = 0; i < envSecrets.length; i++) {
const secret = envSecrets[i];
this.trigger_meta.webhook_body = ReplaceAllOccurrences(
this.trigger_meta.webhook_body,
secret.find,
secret.replace,
);
}
}
}
transformData(data) {
if (!!!this.trigger_meta.has_webhook_body) return JSON.stringify(data);
if (!!!this.trigger_meta.webhook_body) return JSON.stringify(data);
let body = this.trigger_meta.webhook_body;
body = ReplaceAllOccurrences(body, "${id}", data.id);
body = ReplaceAllOccurrences(body, "${alert_name}", data.alert_name);
body = ReplaceAllOccurrences(body, "${severity}", data.severity);
body = ReplaceAllOccurrences(body, "${status}", data.status);
body = ReplaceAllOccurrences(body, "${source}", data.source);
body = ReplaceAllOccurrences(body, "${timestamp}", data.timestamp);
body = ReplaceAllOccurrences(body, "${description}", data.description);
body = ReplaceAllOccurrences(body, "${metric}", data.details.metric);
body = ReplaceAllOccurrences(body, "${current_value}", data.details.current_value);
body = ReplaceAllOccurrences(body, "${threshold}", data.details.threshold);
body = ReplaceAllOccurrences(body, "${action_text}", data.actions[0].text);
body = ReplaceAllOccurrences(body, "${action_url}", data.actions[0].url);
return body;
}
transformData(data) {
if (!!!this.trigger_meta.has_webhook_body) return JSON.stringify(data);
if (!!!this.trigger_meta.webhook_body) return JSON.stringify(data);
let body = this.trigger_meta.webhook_body;
body = ReplaceAllOccurrences(body, "${id}", data.id);
body = ReplaceAllOccurrences(body, "${alert_name}", data.alert_name);
body = ReplaceAllOccurrences(body, "${severity}", data.severity);
body = ReplaceAllOccurrences(body, "${status}", data.status);
body = ReplaceAllOccurrences(body, "${source}", data.source);
body = ReplaceAllOccurrences(body, "${timestamp}", data.timestamp);
body = ReplaceAllOccurrences(body, "${description}", data.description);
body = ReplaceAllOccurrences(body, "${metric}", data.details.metric);
body = ReplaceAllOccurrences(body, "${current_value}", data.details.current_value);
body = ReplaceAllOccurrences(body, "${threshold}", data.details.threshold);
body = ReplaceAllOccurrences(body, "${action_text}", data.actions[0].text);
body = ReplaceAllOccurrences(body, "${action_url}", data.actions[0].url);
return body;
}
type() {
return "webhook";
}
type() {
return "webhook";
}
async send(data) {
try {
const response = await fetch(this.url, {
method: this.method,
headers: this.headers,
body: this.transformData(data)
});
return response;
} catch (error) {
console.error("Error sending webhook", error);
return error;
}
}
async send(data) {
try {
const response = await fetch(this.url, {
method: this.method,
headers: this.headers,
body: this.transformData(data),
});
return response;
} catch (error) {
console.error("Error sending webhook", error);
return error;
}
}
}
export default Webhook;
+17 -18
View File
@@ -81,19 +81,20 @@ function getCountOfSimilarStatuesEnd(arr, statusType) {
const FetchData = async function (site, monitor, localTz, selectedLang, lang) {
const secondsInDay = 24 * 60 * 60;
//get offset from utc in minutes
const now = GetMinuteStartNowTimestampUTC() + 60;
const midnight = BeginningOfDay({ timeZone: localTz });
const midnight90DaysAgo = midnight - 90 * 24 * 60 * 60;
const nowUTC = GetMinuteStartNowTimestampUTC();
const midnightUTC = GetDayStartTimestampUTC(nowUTC);
const midnightTz = BeginningOfDay({ timeZone: localTz });
const midnight90DaysAgoTz = midnightTz - 90 * 24 * 60 * 60;
const NO_DATA = "No Data";
let offsetInMinutes = parseInt((GetDayStartTimestampUTC(now) - midnight) / 60);
const maxDateTodayTimestamp = BeginningOfMinute({ timeZone: localTz });
let offsetInMinutes = parseInt((GetDayStartTimestampUTC(nowUTC) - midnightTz) / 60);
const maxDateTodayTimestampTz = BeginningOfMinute({ timeZone: localTz });
const _90Day = {};
let latestTimestamp = 0;
let dbData = await GetDataGroupByDayAlternative(
monitor.tag,
midnight90DaysAgo,
maxDateTodayTimestamp,
midnight90DaysAgoTz,
maxDateTodayTimestampTz,
offsetInMinutes,
);
let totalDegradedCount = 0;
@@ -110,7 +111,6 @@ const FetchData = async function (site, monitor, localTz, selectedLang, lang) {
totalDegradedCount += dayData.DEGRADED;
totalDownCount += dayData.DOWN;
totalUpCount += dayData.UP;
if (dayData.DEGRADED >= monitor.day_degraded_minimum_count) {
cssClass = returnStatusClass(dayData.DEGRADED, dayData.total, StatusObj.DEGRADED, site.barStyle);
summaryDuration = getSummaryDuration(dayData.DEGRADED, selectedLang);
@@ -156,14 +156,9 @@ const FetchData = async function (site, monitor, localTz, selectedLang, lang) {
let summaryColorClass = "api-nodata";
let todayDataDb = await db.getMonitoringData(monitor.tag, midnight, midnight + secondsInDay);
let anchorStatus = await GetLastStatusBefore(monitor.tag, midnight);
todayDataDb = InterpolateData(
todayDataDb,
midnight,
anchorStatus,
todayDataDb.length == 0 ? midnight + secondsInDay : null,
);
let todayDataDb = await db.getMonitoringData(monitor.tag, midnightTz, maxDateTodayTimestampTz);
let anchorStatus = await GetLastStatusBefore(monitor.tag, midnightTz);
todayDataDb = InterpolateData(todayDataDb, midnightTz, anchorStatus, maxDateTodayTimestampTz);
if (site.summaryStyle === "CURRENT") {
summaryColorClass = "api-up";
@@ -180,6 +175,10 @@ const FetchData = async function (site, monitor, localTz, selectedLang, lang) {
summaryStatus = "DOWN";
summaryColorClass = "api-down";
}
if (lastRow.status === "NO_DATA") {
summaryStatus = NO_DATA;
summaryColorClass = "api-nodata";
}
summaryStatus = l(lang, summaryTime(summaryStatus), {
status: l(lang, summaryStatus),
duration: summaryDuration,
@@ -196,8 +195,8 @@ const FetchData = async function (site, monitor, localTz, selectedLang, lang) {
summaryStatus: summaryStatus,
summaryColorClass: summaryColorClass,
barRoundness: site.barRoundness,
midnight90DaysAgo: midnight90DaysAgo,
maxDateTodayTimestamp: maxDateTodayTimestamp,
midnight90DaysAgo: midnight90DaysAgoTz,
maxDateTodayTimestamp: maxDateTodayTimestampTz,
};
};
export { FetchData };
+44
View File
@@ -0,0 +1,44 @@
// @ts-nocheck
import { UP, DOWN, DEGRADED, REALTIME, TIMEOUT, ERROR, MANUAL } from "../constants.js";
import { GetMinuteStartNowTimestampUTC } from "../tool.js";
import { GetLastHeartbeat } from "../controllers/controller.js";
class HeartbeatCall {
monitor;
constructor(monitor) {
this.monitor = monitor;
}
async execute() {
let nowMinute = GetMinuteStartNowTimestampUTC();
let latestData = await GetLastHeartbeat(this.monitor.tag);
if (!latestData) {
return {};
}
let latency = nowMinute - latestData.timestamp;
let downRemainingMinutes = Number(this.monitor.type_data.downRemainingMinutes);
if (latency > downRemainingMinutes * 60) {
return {
status: DOWN,
latency: latency,
type: REALTIME,
};
}
let degradedRemainingMinutes = Number(this.monitor.type_data.degradedRemainingMinutes);
if (latency > degradedRemainingMinutes * 60) {
return {
status: DEGRADED,
latency: latency,
type: REALTIME,
};
}
return {
status: UP,
latency: latency,
type: REALTIME,
};
}
}
export default HeartbeatCall;
+3
View File
@@ -6,6 +6,7 @@ import DnsCall from "./dnsCall.js";
import GroupCall from "./groupCall.js";
import SSLCall from "./sslCall.js";
import SqlCall from "./sqlCall.js";
import HeartbeatCall from "./heartbeatCall.js";
class Service {
service;
@@ -27,6 +28,8 @@ class Service {
this.service = new SSLCall(monitor);
} else if (monitor.monitor_type === "SQL") {
this.service = new SqlCall(monitor);
} else if (monitor.monitor_type === "HEARTBEAT") {
this.service = new HeartbeatCall(monitor);
} else {
console.log("Invalid monitor.monitor_type ", monitor.monitor_type);
process.exit(1);
+20 -4
View File
@@ -1,19 +1,32 @@
// @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 { fileURLToPath } from "url";
import { dirname } from "path";
import { dirname, resolve } from "path";
import fs from "fs";
const jobs = [];
process.env.TZ = "UTC";
let isStartUP = true;
// Get the version from package.json
const getVersion = () => {
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 (error) {
console.error("Error reading version:", error);
return "unknown";
}
};
const scheduleCronJobs = async () => {
// Fetch and map all active monitors, creating a unique hash for each
const activeMonitors = (await GetMonitorsParsed({ status: "ACTIVE" })).map((monitor) => ({
@@ -72,12 +85,15 @@ async function Startup() {
mainJob.trigger();
figlet("Kener is UP!", function (err, data) {
const version = getVersion();
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!`);
});
}
+236 -243
View File
@@ -1,325 +1,318 @@
// @ts-nocheck
import { AllRecordTypes } from "./constants.js";
import knexOb from "../../../knexfile.js";
import dotenv from "dotenv";
dotenv.config();
const IsValidURL = function (url) {
return /^(http|https):\/\/[^ "]+$/.test(url);
return /^(http|https):\/\/[^ "]+$/.test(url);
};
const IsStringURLSafe = function (str) {
const regex = /^[A-Za-z0-9\-_.~]+$/;
return regex.test(str);
const regex = /^[A-Za-z0-9\-_.~]+$/;
return regex.test(str);
};
const IsValidHTTPMethod = function (method) {
return /^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH)$/.test(method);
return /^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH)$/.test(method);
};
//return given timestamp in UTC
const GetNowTimestampUTC = function () {
//use js date instead of moment
const now = new Date();
const timestamp = now.getTime();
return Math.floor(timestamp / 1000);
//use js date instead of moment
const now = new Date();
const timestamp = now.getTime();
return Math.floor(timestamp / 1000);
};
//return given timestamp minute start timestamp in UTC
const GetMinuteStartTimestampUTC = function (timestamp) {
//use js date instead of moment
const now = new Date(timestamp * 1000);
const minuteStart = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
now.getHours(),
now.getMinutes(),
0,
0
);
const minuteStartTimestamp = minuteStart.getTime();
return Math.floor(minuteStartTimestamp / 1000);
//use js date instead of moment
const now = new Date(timestamp * 1000);
const minuteStart = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
now.getHours(),
now.getMinutes(),
0,
0,
);
const minuteStartTimestamp = minuteStart.getTime();
return Math.floor(minuteStartTimestamp / 1000);
};
//return current timestamp minute start timestamp in UTC
const GetMinuteStartNowTimestampUTC = function () {
//use js date instead of moment
const now = new Date();
const minuteStart = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
now.getHours(),
now.getMinutes(),
0,
0
);
const minuteStartTimestamp = minuteStart.getTime();
return Math.floor(minuteStartTimestamp / 1000);
//use js date instead of moment
const now = new Date();
const minuteStart = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
now.getHours(),
now.getMinutes(),
0,
0,
);
const minuteStartTimestamp = minuteStart.getTime();
return Math.floor(minuteStartTimestamp / 1000);
};
//return given timestamp day start timestamp in UTC
const GetDayStartTimestampUTC = function (timestamp) {
//use js date instead of moment
const now = new Date(timestamp * 1000);
const dayStart = new Date(
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0)
);
const dayStartTimestamp = dayStart.getTime();
return Math.floor(dayStartTimestamp / 1000);
//use js date instead of moment
const now = new Date(timestamp * 1000);
const dayStart = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0));
const dayStartTimestamp = dayStart.getTime();
return Math.floor(dayStartTimestamp / 1000);
};
const GetDayEndTimestampUTC = function (timestamp) {
//use js date instead of moment
const now = new Date(timestamp * 1000);
const dayEnd = new Date(
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999)
);
const dayEndTimestamp = dayEnd.getTime();
return Math.floor(dayEndTimestamp / 1000) + 60;
//use js date instead of moment
const now = new Date(timestamp * 1000);
const dayEnd = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999));
const dayEndTimestamp = dayEnd.getTime();
return Math.floor(dayEndTimestamp / 1000) + 60;
};
const DurationInMinutes = function (start, end) {
return Math.floor((end - start) / 60);
return Math.floor((end - start) / 60);
};
const GetDayStartWithOffset = function (timeStampInSeconds, offsetInMinutes) {
const then = new Date(GetMinuteStartTimestampUTC(timeStampInSeconds) * 1000);
let dayStartThen = GetDayStartTimestampUTC(then.getTime() / 1000);
let dayStartTomorrow = dayStartThen + 24 * 60 * 60;
let dayStartYesterday = dayStartThen - 24 * 60 * 60;
//have to figure out when to add a day
//20-12AM [21-12AM] =21:630 xtm [22-12AM] xtd =22:630 23-12AM
const then = new Date(GetMinuteStartTimestampUTC(timeStampInSeconds) * 1000);
let dayStartThen = GetDayStartTimestampUTC(then.getTime() / 1000);
let dayStartTomorrow = dayStartThen + 24 * 60 * 60;
let dayStartYesterday = dayStartThen - 24 * 60 * 60;
//have to figure out when to add a day
//20-12AM [21-12AM] =21:630 xtm [22-12AM] xtd =22:630 23-12AM
//if xtm - 330 > 1 day , add a day to xtm - 330
//if xtm - 330 > 1 day , add a day to xtm - 330
if (offsetInMinutes < 0) {
//add one day to dayStartThen
dayStartThen = dayStartThen + 24 * 60 * 60;
}
return dayStartThen + offsetInMinutes * 60;
if (offsetInMinutes < 0) {
//add one day to dayStartThen
dayStartThen = dayStartThen + 24 * 60 * 60;
}
return dayStartThen + offsetInMinutes * 60;
};
const BeginningOfDay = (options = {}) => {
const { date = new Date(), timeZone } = options;
const parts = Intl.DateTimeFormat("en-US", {
timeZone,
hourCycle: "h23",
hour: "numeric",
minute: "numeric",
second: "numeric"
}).formatToParts(date);
const hour = parseInt(parts.find((i) => i.type === "hour").value);
const minute = parseInt(parts.find((i) => i.type === "minute").value);
const second = parseInt(parts.find((i) => i.type === "second").value);
const dt = new Date(
1000 * Math.floor((date - hour * 3600000 - minute * 60000 - second * 1000) / 1000)
);
return dt.getTime() / 1000;
const { date = new Date(), timeZone } = options;
const parts = Intl.DateTimeFormat("en-US", {
timeZone,
hourCycle: "h23",
hour: "numeric",
minute: "numeric",
second: "numeric",
}).formatToParts(date);
const hour = parseInt(parts.find((i) => i.type === "hour").value);
const minute = parseInt(parts.find((i) => i.type === "minute").value);
const second = parseInt(parts.find((i) => i.type === "second").value);
const dt = new Date(1000 * Math.floor((date - hour * 3600000 - minute * 60000 - second * 1000) / 1000));
return dt.getTime() / 1000;
};
const BeginningOfMinute = (options = {}) => {
const { date = new Date(), timeZone } = options;
const parts = Intl.DateTimeFormat("en-US", {
timeZone,
hourCycle: "h23",
second: "numeric"
}).formatToParts(date);
const second = parseInt(parts.find((i) => i.type === "second").value);
const dt = new Date(1000 * Math.floor((date - second * 1000) / 1000));
return dt.getTime() / 1000;
const { date = new Date(), timeZone } = options;
const parts = Intl.DateTimeFormat("en-US", {
timeZone,
hourCycle: "h23",
second: "numeric",
}).formatToParts(date);
const second = parseInt(parts.find((i) => i.type === "second").value);
const dt = new Date(1000 * Math.floor((date - second * 1000) / 1000));
return dt.getTime() / 1000;
};
const ValidateIpAddress = function (input) {
// Check if input is a valid IPv4 address
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (ipv4Regex.test(input)) {
return "IPv4";
}
// Check if input is a valid IPv4 address
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (ipv4Regex.test(input)) {
return "IPv4";
}
// Check if input is a valid IPv6 address
const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
if (ipv6Regex.test(input)) {
return "IPv6";
}
// Check if input is a valid IPv6 address
const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
if (ipv6Regex.test(input)) {
return "IPv6";
}
// Check if input is a valid domain name
const domainRegex = /^[a-zA-Z0-9]+([\-\.]{1}[a-zA-Z0-9]+)*\.[a-zA-Z]{2,}$/;
if (domainRegex.test(input)) {
return "Domain Name";
}
// Check if input is a valid domain name
const domainRegex = /^[a-zA-Z0-9]+([\-\.]{1}[a-zA-Z0-9]+)*\.[a-zA-Z]{2,}$/;
if (domainRegex.test(input)) {
return "Domain Name";
}
// If none of the above conditions match, the input is invalid
return "Invalid";
// If none of the above conditions match, the input is invalid
return "Invalid";
};
function checkIfDuplicateExists(arr) {
return new Set(arr).size !== arr.length;
return new Set(arr).size !== arr.length;
}
function GetWordsStartingWithDollar(text) {
const regex = /\$\w+/g;
const wordsArray = text.match(regex);
return wordsArray || [];
const regex = /\$\w+/g;
const wordsArray = text.match(regex);
return wordsArray || [];
}
const StatusObj = {
UP: "api-up",
DEGRADED: "api-degraded",
DOWN: "api-down",
NO_DATA: "api-nodata"
UP: "api-up",
DEGRADED: "api-degraded",
DOWN: "api-down",
NO_DATA: "api-nodata",
};
// @ts-ignore
const ParseUptime = function (up, all) {
if (all === 0) return String("-");
if (up == 0) return String("0");
if (up == all) {
return String(((up / all) * parseFloat(100)).toFixed(0));
}
//return 50% as 50% and not 50.0000%
if (((up / all) * 100) % 10 == 0) {
return String(((up / all) * parseFloat(100)).toFixed(0));
}
return String(((up / all) * parseFloat(100)).toFixed(4));
if (all === 0) return String("-");
if (up == 0) return String("0");
if (up == all) {
return String(((up / all) * parseFloat(100)).toFixed(0));
}
//return 50% as 50% and not 50.0000%
if (((up / all) * 100) % 10 == 0) {
return String(((up / all) * parseFloat(100)).toFixed(0));
}
return String(((up / all) * parseFloat(100)).toFixed(4));
};
const ParsePercentage = function (n) {
if (isNaN(n)) return "-";
if (n == 0) {
return "0";
}
if (n == 100) {
return "100";
}
return n.toFixed(4);
if (isNaN(n)) return "-";
if (n == 0) {
return "0";
}
if (n == 100) {
return "100";
}
return n.toFixed(4);
};
//valid domain name
function IsValidHost(domain) {
const regex = /^[a-zA-Z0-9]+([\-\.]{1}[a-zA-Z0-9]+)*\.[a-zA-Z]{2,}$/;
return regex.test(domain);
const regex = /^[a-zA-Z0-9]+([\-\.]{1}[a-zA-Z0-9]+)*\.[a-zA-Z]{2,}$/;
return regex.test(domain);
}
//valid nameserver
function IsValidNameServer(nameServer) {
//8.8.8.8 example
const regex = /^([0-9]{1,3}\.){3}[0-9]{1,3}$/;
return regex.test(nameServer);
//8.8.8.8 example
const regex = /^([0-9]{1,3}\.){3}[0-9]{1,3}$/;
return regex.test(nameServer);
}
//valid dns record type
function IsValidRecordType(recordType) {
return AllRecordTypes.hasOwnProperty(recordType);
return AllRecordTypes.hasOwnProperty(recordType);
}
function ReplaceAllOccurrences(originalString, searchString, replacement) {
const regex = new RegExp(`\\${searchString}`, "g");
const replacedString = originalString.replace(regex, replacement);
return replacedString;
const regex = new RegExp(`\\${searchString}`, "g");
const replacedString = originalString.replace(regex, replacement);
return replacedString;
}
function GetRequiredSecrets(str) {
let envSecrets = [];
const requiredSecrets = GetWordsStartingWithDollar(str).map((x) => x.substr(1));
for (const [key, value] of Object.entries(process.env)) {
if (requiredSecrets.indexOf(key) !== -1) {
envSecrets.push({
find: `$${key}`,
replace: value
});
}
}
return envSecrets;
let envSecrets = [];
const requiredSecrets = GetWordsStartingWithDollar(str).map((x) => x.substr(1));
for (const [key, value] of Object.entries(process.env)) {
if (requiredSecrets.indexOf(key) !== -1) {
envSecrets.push({
find: `$${key}`,
replace: value,
});
}
}
return envSecrets;
}
function ValidateMonitorAlerts(alerts) {
if (!alerts) {
console.log("Alerts object is not provided.");
return false;
}
// if down degraded not present return false
if (!alerts.hasOwnProperty("DOWN") && !alerts.hasOwnProperty("DEGRADED")) {
console.log("Alerts object does not have DOWN or DEGRADED properties.");
return false;
}
let statues = Object.keys(alerts);
//can be either DOWN or DEGRADED
let validKeys = ["DOWN", "DEGRADED"];
for (let i = 0; i < statues.length; i++) {
const keyName = statues[i];
if (!validKeys.includes(keyName)) {
console.log(`Invalid key found in alerts: ${keyName}`);
return false;
}
}
for (const key in alerts) {
if (Object.prototype.hasOwnProperty.call(alerts, key)) {
const element = alerts[key];
const triggers = element.triggers;
//if triggers not present return false
if (!element.hasOwnProperty("triggers")) {
console.log(`Triggers not present for key: ${key}`);
return false;
}
//if length of triggers is 0 return false
if (triggers.length === 0) {
console.log(`Triggers length is 0 for key: ${key}`);
return false;
}
if (
!element.hasOwnProperty("failureThreshold") ||
!element.hasOwnProperty("successThreshold")
) {
console.log(`Thresholds not present for key: ${key}`);
return false;
}
if (
element.hasOwnProperty("failureThreshold") &&
!Number.isInteger(element.failureThreshold)
) {
console.log(`Failure threshold is not an integer for key: ${key}`);
return false;
}
if (
element.hasOwnProperty("successThreshold") &&
!Number.isInteger(element.successThreshold)
) {
console.log(`Success threshold is not an integer for key: ${key}`);
return false;
}
if (element.hasOwnProperty("failureThreshold") && element.failureThreshold <= 0) {
console.log(`Failure threshold is less than or equal to 0 for key: ${key}`);
return false;
}
if (element.hasOwnProperty("successThreshold") && element.successThreshold <= 0) {
console.log(`Success threshold is less than or equal to 0 for key: ${key}`);
return false;
}
}
}
return true;
if (!alerts) {
console.log("Alerts object is not provided.");
return false;
}
// if down degraded not present return false
if (!alerts.hasOwnProperty("DOWN") && !alerts.hasOwnProperty("DEGRADED")) {
console.log("Alerts object does not have DOWN or DEGRADED properties.");
return false;
}
let statues = Object.keys(alerts);
//can be either DOWN or DEGRADED
let validKeys = ["DOWN", "DEGRADED"];
for (let i = 0; i < statues.length; i++) {
const keyName = statues[i];
if (!validKeys.includes(keyName)) {
console.log(`Invalid key found in alerts: ${keyName}`);
return false;
}
}
for (const key in alerts) {
if (Object.prototype.hasOwnProperty.call(alerts, key)) {
const element = alerts[key];
const triggers = element.triggers;
//if triggers not present return false
if (!element.hasOwnProperty("triggers")) {
console.log(`Triggers not present for key: ${key}`);
return false;
}
//if length of triggers is 0 return false
if (triggers.length === 0) {
console.log(`Triggers length is 0 for key: ${key}`);
return false;
}
if (!element.hasOwnProperty("failureThreshold") || !element.hasOwnProperty("successThreshold")) {
console.log(`Thresholds not present for key: ${key}`);
return false;
}
if (element.hasOwnProperty("failureThreshold") && !Number.isInteger(element.failureThreshold)) {
console.log(`Failure threshold is not an integer for key: ${key}`);
return false;
}
if (element.hasOwnProperty("successThreshold") && !Number.isInteger(element.successThreshold)) {
console.log(`Success threshold is not an integer for key: ${key}`);
return false;
}
if (element.hasOwnProperty("failureThreshold") && element.failureThreshold <= 0) {
console.log(`Failure threshold is less than or equal to 0 for key: ${key}`);
return false;
}
if (element.hasOwnProperty("successThreshold") && element.successThreshold <= 0) {
console.log(`Success threshold is less than or equal to 0 for key: ${key}`);
return false;
}
}
}
return true;
}
function GenerateRandomColor() {
var randomColor = Math.floor(Math.random() * 16777215).toString(16);
return randomColor;
var randomColor = Math.floor(Math.random() * 16777215).toString(16);
return randomColor;
}
//wait for x ms promise
function Wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
return new Promise((resolve) => setTimeout(resolve, ms));
}
function MaskString(str) {
return "*".repeat(str.length - 4) + str.slice(-4);
return "*".repeat(str.length - 4) + str.slice(-4);
}
function GetDbType() {
//sqlite, postgresql, mysql
return knexOb.databaseType;
}
export {
IsValidURL,
IsValidHTTPMethod,
GetMinuteStartTimestampUTC,
GetNowTimestampUTC,
GetDayStartTimestampUTC,
GetMinuteStartNowTimestampUTC,
DurationInMinutes,
GetDayStartWithOffset,
BeginningOfDay,
IsStringURLSafe,
ValidateIpAddress,
checkIfDuplicateExists,
GetWordsStartingWithDollar,
StatusObj,
ParseUptime,
ParsePercentage,
IsValidHost,
IsValidRecordType,
IsValidNameServer,
ReplaceAllOccurrences,
GetRequiredSecrets,
ValidateMonitorAlerts,
GenerateRandomColor,
BeginningOfMinute,
Wait,
MaskString
IsValidURL,
IsValidHTTPMethod,
GetMinuteStartTimestampUTC,
GetNowTimestampUTC,
GetDayStartTimestampUTC,
GetMinuteStartNowTimestampUTC,
DurationInMinutes,
GetDayStartWithOffset,
BeginningOfDay,
IsStringURLSafe,
ValidateIpAddress,
checkIfDuplicateExists,
GetWordsStartingWithDollar,
StatusObj,
ParseUptime,
ParsePercentage,
IsValidHost,
IsValidRecordType,
IsValidNameServer,
ReplaceAllOccurrences,
GetRequiredSecrets,
ValidateMonitorAlerts,
GenerateRandomColor,
BeginningOfMinute,
Wait,
MaskString,
GetDbType,
};
+47 -2
View File
@@ -6,6 +6,7 @@
import Sun from "lucide-svelte/icons/sun";
import Menu from "lucide-svelte/icons/menu";
import X from "lucide-svelte/icons/x";
import Heart from "lucide-svelte/icons/heart";
import { clickOutsideAction, slide } from "svelte-legos";
import Moon from "lucide-svelte/icons/moon";
import { onMount } from "svelte";
@@ -56,6 +57,23 @@
if (window.innerWidth > 768) {
sideBarHidden = false;
}
const donationBanner = document.getElementById("donation-banner");
const closeButton = document.getElementById("close-donation");
// Check if we should show the banner today
const lastShown = localStorage.getItem("kener_donation_banner_closed");
const today = new Date().toDateString();
// Only show the banner if it hasn't been closed today
if (lastShown !== today) {
donationBanner.style.display = "block";
}
// Add close button handler
closeButton.addEventListener("click", () => {
donationBanner.style.display = "none";
localStorage.setItem("kener_donation_banner_closed", today);
});
});
</script>
@@ -92,7 +110,7 @@
<!-- Document Icon - Replace with your own logo -->
<img src="https://kener.ing/logo.png" class="h-8 w-8" alt="" />
<span class="text-xl font-medium">Kener Documentation</span>
<span class="me-2 rounded border px-2.5 py-0.5 text-xs font-medium"> 3.2.0 </span>
<span class="me-2 rounded border px-2.5 py-0.5 text-xs font-medium"> 3.2.4 </span>
</a>
</div>
@@ -107,7 +125,6 @@
</a>
<a href="/api-reference" class="text-sm font-medium"> API Reference </a>
<a href="https://github.com/rajnandan1/kener/issues" class="text-sm font-medium"> Report Issue </a>
<a href="https://github.com/sponsors/rajnandan1" class="text-sm font-medium"> Sponsor </a>
</div>
</div>
@@ -168,6 +185,34 @@
<main class="dark relative z-10 min-h-screen pt-16 md:ml-72">
<div class="mx-auto max-w-5xl px-4 py-10 sm:px-6 md:px-8 md:pr-64">
<!-- Content Header -->
<div
id="donation-banner"
style="display: none;"
class="mb-8 rounded-lg bg-gradient-to-r from-blue-900 to-indigo-900 p-4 shadow-lg"
>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="rounded-full bg-amber-500 p-2">
<Heart class="h-5 w-5 text-white" />
</div>
<div>
<h3 class="text-base font-medium text-white">Support Kener Development</h3>
<p class="text-sm text-blue-200">Help us keep this project running and improve it further</p>
</div>
</div>
<div class="flex items-center space-x-3">
<a
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
</a>
<button id="close-donation" class="rounded p-1 text-blue-300 hover:bg-blue-800 hover:text-white">
<X class="h-5 w-5" />
</button>
</div>
</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"
>
+13 -11
View File
@@ -186,7 +186,7 @@
</div>
</footer>
{/if}
<div class="fixed bottom-4 right-4 z-20 rounded-md bg-background">
<div class="fixed bottom-4 right-4 z-20 flex flex-col rounded-md bg-background">
{#if !!data.site.tzToggle && data.site.tzToggle === "YES"}
<div>
<Popover.Root>
@@ -198,8 +198,8 @@
<Popover.Content class="px-0 py-2">
<div class="flex flex-col gap-2">
<div class="flex px-2 pb-1 text-xs font-semibold">
<span class="flex-1"
>{@html l(data.lang, "Timezone set to %tz", {
<span class="flex-1">
{@html l(data.lang, "Timezone set to %tz", {
tz: "<i>" + data.localTz + "</i>"
})}
</span>
@@ -271,17 +271,19 @@
</div>
{/if}
{#if !!data.site.themeToggle && data.site.themeToggle === "YES"}
<Button on:click={toggleMode} variant="ghost" size="icon" class="flex">
<Sun class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span class="sr-only">Toggle theme</span>
</Button>
<div>
<Button on:click={toggleMode} variant="ghost" size="icon" class="flex">
<Sun class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span class="sr-only">Toggle theme</span>
</Button>
</div>
{/if}
</div>
{#if data.isLoggedIn}
<a href="{base}/manage/app/site" rel="external" class="button-77 fixed bottom-8 left-8 z-50" role="button">
Manage
</a>
<div class="fixed bottom-2 left-2 z-50 md:bottom-8 md:left-8">
<a href="{base}/manage/app/site" rel="external" class="button-77" role="button"> Manage </a>
</div>
{/if}
</main>
@@ -0,0 +1,39 @@
// @ts-nocheck
// @ts-ignore
import { json } from "@sveltejs/kit";
import { RegisterHeartbeat } from "$lib/server/controllers/controller";
export async function GET({ request, params }) {
let signal = params.signal;
let signalData = signal.split(":");
if (signalData.length != 2) {
return json({ error: "Invalid url" }, { status: 400 });
}
let resp = await RegisterHeartbeat(signalData[0], signalData[1]);
if (!!!resp) {
return json({ error: "Invalid heartbeat url" }, { status: 400 });
}
return json(
{ status: "OK" },
{
status: 200,
},
);
}
export async function POST({ request, params }) {
let signal = params.signal;
let signalData = signal.split(":");
if (signalData.length != 2) {
return json({ error: "Invalid url" }, { status: 400 });
}
let resp = await RegisterHeartbeat(signalData[0], signalData[1]);
if (!!!resp) {
return json({ error: "Invalid heartbeat url" }, { status: 400 });
}
return json(
{ status: "OK" },
{
status: 200,
},
);
}
+4 -5
View File
@@ -17,9 +17,8 @@ export async function POST({ request }) {
const incidentIDs = payload.incidentIDs || [];
let _0Day = {};
const now = GetMinuteStartNowTimestampUTC() + 60;
const start = payload.startTs;
let end = Math.min(payload.startTs + 24 * 60 * 60, now);
let end = payload.endTs;
for (let i = start; i < end; i += 60) {
_0Day[i] = {
@@ -30,9 +29,9 @@ export async function POST({ request }) {
};
}
let dayData = await db.getMonitoringData(monitor.tag, payload.startTs, end);
let dayData = await db.getMonitoringData(monitor.tag, payload.startTs, payload.endTs);
let anchorStatus = await GetLastStatusBefore(monitor.tag, start);
dayData = InterpolateData(dayData, payload.startTs, anchorStatus, end);
dayData = InterpolateData(dayData, payload.startTs, anchorStatus, payload.endTs);
let siteData = await GetAllSiteData();
let ups = 0;
@@ -60,7 +59,7 @@ export async function POST({ request }) {
ups++;
} else if (status == "DOWN") {
downs++;
} else {
} else if (status == "DEGRADED") {
degradeds++;
}
}
@@ -13,7 +13,7 @@ export async function POST({ request }) {
let rawData = await db.getMonitoringData(monitor.tag, start, end);
let anchorStatus = await GetLastStatusBefore(monitor.tag, start);
rawData = InterpolateData(rawData, start, anchorStatus, end - 60);
rawData = InterpolateData(rawData, start, anchorStatus, end);
let aggregatedData = rawData.reduce(
(acc, row) => {
@@ -37,7 +37,7 @@ export async function GET({ params, url }) {
let todayDataDb = await db.getMonitoringData(tag, since, now);
let anchorStatus = await GetLastStatusBefore(tag, since);
todayDataDb = InterpolateData(todayDataDb, since, anchorStatus, now - 60);
todayDataDb = InterpolateData(todayDataDb, since, anchorStatus, now);
let calculatedData = AggregateData(todayDataDb);
@@ -158,18 +158,18 @@
<!-- List -->
<ul class="flex flex-wrap items-center">
<li
class="before:size-[3px] relative inline-block pe-4 text-xs before:absolute before:end-1.5 before:top-1/2 before:-translate-y-1/2 before:rounded-full before:bg-gray-400 last:pe-0 last-of-type:before:hidden dark:text-neutral-500 dark:before:bg-neutral-600"
class="relative inline-block pe-4 text-xs before:absolute before:end-1.5 before:top-1/2 before:size-[3px] before:-translate-y-1/2 before:rounded-full before:bg-gray-400 last:pe-0 last-of-type:before:hidden dark:text-neutral-500 dark:before:bg-neutral-600"
>
<a
target="_blank"
class="text-xs font-medium text-muted-foreground hover:underline hover:decoration-1 focus:decoration-1 focus:outline-none"
href="https://github.com/sponsors/rajnandan1"
href="https://buymeacoffee.com/rajnandan1"
>
Donate
</a>
</li>
<li
class="before:size-[3px] relative inline-block pe-4 text-xs before:absolute before:end-1.5 before:top-1/2 before:-translate-y-1/2 before:rounded-full before:bg-gray-400 last:pe-0 last-of-type:before:hidden dark:text-neutral-500 dark:before:bg-neutral-600"
class="relative inline-block pe-4 text-xs before:absolute before:end-1.5 before:top-1/2 before:size-[3px] before:-translate-y-1/2 before:rounded-full before:bg-gray-400 last:pe-0 last-of-type:before:hidden dark:text-neutral-500 dark:before:bg-neutral-600"
>
<a
target="_blank"
@@ -180,7 +180,7 @@
</a>
</li>
<li
class="before:size-[3px] relative inline-block pe-4 text-xs before:absolute before:end-1.5 before:top-1/2 before:-translate-y-1/2 before:rounded-full before:bg-gray-400 last:pe-0 last-of-type:before:hidden dark:text-neutral-500 dark:before:bg-neutral-600"
class="relative inline-block pe-4 text-xs before:absolute before:end-1.5 before:top-1/2 before:size-[3px] before:-translate-y-1/2 before:rounded-full before:bg-gray-400 last:pe-0 last-of-type:before:hidden dark:text-neutral-500 dark:before:bg-neutral-600"
>
<a
target="_blank"
@@ -191,7 +191,7 @@
</a>
</li>
<li
class="before:size-[3px] relative inline-block pe-4 text-xs before:absolute before:end-1.5 before:top-1/2 before:-translate-y-1/2 before:rounded-full before:bg-gray-400 last:pe-0 last-of-type:before:hidden dark:text-neutral-500 dark:before:bg-neutral-600"
class="relative inline-block pe-4 text-xs before:absolute before:end-1.5 before:top-1/2 before:size-[3px] before:-translate-y-1/2 before:rounded-full before:bg-gray-400 last:pe-0 last-of-type:before:hidden dark:text-neutral-500 dark:before:bg-neutral-600"
>
<a
target="_blank"
@@ -633,7 +633,6 @@
variant="ghost"
class="h-5 w-5 p-1"
size="icon"
disabled={incident.isAutoCreated}
on:click={(e) => {
showEditModal(incident);
}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

+61 -61
View File
@@ -2,67 +2,67 @@ import { fontFamily } from "tailwindcss/defaultTheme";
/** @type {import('tailwindcss').Config} */
const config = {
darkMode: ["class"],
content: ["./src/**/*.{html,js,svelte,ts}"],
safelist: ["dark"],
plugins: [
require("@tailwindcss/typography")
// ...
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px"
}
},
extend: {
colors: {
border: "hsl(var(--border) / <alpha-value>)",
input: "hsl(var(--input) / <alpha-value>)",
ring: "hsl(var(--ring) / <alpha-value>)",
background: "hsl(var(--background) / <alpha-value>)",
foreground: "hsl(var(--foreground) / <alpha-value>)",
primary: {
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
foreground: "hsl(var(--primary-foreground) / <alpha-value>)"
},
secondary: {
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)"
},
destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)"
},
muted: {
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
foreground: "hsl(var(--muted-foreground) / <alpha-value>)"
},
accent: {
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
foreground: "hsl(var(--accent-foreground) / <alpha-value>)"
},
popover: {
DEFAULT: "hsl(var(--popover) / <alpha-value>)",
foreground: "hsl(var(--popover-foreground) / <alpha-value>)"
},
card: {
DEFAULT: "hsl(var(--card) / <alpha-value>)",
foreground: "hsl(var(--card-foreground) / <alpha-value>)"
}
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)"
},
fontFamily: {
sans: [...fontFamily.sans]
}
}
}
darkMode: ["class"],
content: ["./src/**/*.{html,js,svelte,ts}"],
safelist: ["dark"],
plugins: [
require("@tailwindcss/typography"),
// ...
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border) / <alpha-value>)",
input: "hsl(var(--input) / <alpha-value>)",
ring: "hsl(var(--ring) / <alpha-value>)",
background: "hsl(var(--background) / <alpha-value>)",
foreground: "hsl(var(--foreground) / <alpha-value>)",
primary: {
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
foreground: "hsl(var(--primary-foreground) / <alpha-value>)",
},
secondary: {
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)",
},
destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)",
},
muted: {
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
foreground: "hsl(var(--muted-foreground) / <alpha-value>)",
},
accent: {
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
foreground: "hsl(var(--accent-foreground) / <alpha-value>)",
},
popover: {
DEFAULT: "hsl(var(--popover) / <alpha-value>)",
foreground: "hsl(var(--popover-foreground) / <alpha-value>)",
},
card: {
DEFAULT: "hsl(var(--card) / <alpha-value>)",
foreground: "hsl(var(--card-foreground) / <alpha-value>)",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: [...fontFamily.sans],
},
},
},
};
export default config;