Merge pull request #289 from rajnandan1/feature/category-status

Feature/category status
This commit is contained in:
Raj Nandan Sharma
2025-02-16 19:09:22 +05:30
committed by GitHub
73 changed files with 1001 additions and 622 deletions

View File

@@ -58,7 +58,8 @@ COPY . .
# TODO: Reevaluate permissions (possibly reduce?)...
# Remove docs directory and ensure required directories exist
RUN rm -rf src/routes/\(docs\) && \
mkdir -p uploads database && \
rm -rf src/static/documentation && \
mkdir -p uploads database && \
# TODO: Consider changing below to `chmod -R u-rwX,g=rX,o= uploads database`
chmod -R 750 uploads database

View File

@@ -64,24 +64,24 @@ Body of the webhook will be sent as below:
```json
{
"id": "mockoon-9",
"alert_name": "Mockoon DOWN",
"severity": "critical",
"status": "TRIGGERED",
"source": "Kener",
"timestamp": "2024-11-27T04:55:00.369Z",
"description": "🚨 **Service Alert**: Check the details below",
"details": {
"metric": "Mockoon",
"current_value": 1,
"threshold": 1
},
"actions": [
{
"text": "View Monitor",
"url": "https://kener.ing/monitor-mockoon"
}
]
"id": "mockoon-9",
"alert_name": "Mockoon DOWN",
"severity": "critical",
"status": "TRIGGERED",
"source": "Kener",
"timestamp": "2024-11-27T04:55:00.369Z",
"description": "🚨 **Service Alert**: Check the details below",
"details": {
"metric": "Mockoon",
"current_value": 1,
"threshold": 1
},
"actions": [
{
"text": "View Monitor",
"url": "https://kener.ing/monitor-mockoon"
}
]
}
```
@@ -108,7 +108,7 @@ The discord message when alert is `TRIGGERED` will look like this
The discord message when alert is `RESOLVED` will look like this
![Discord](/discord_resolved.png)
![Discord](/documentation/discord_resolved.png)
### Slack
@@ -118,7 +118,7 @@ The slack message when alert is `TRIGGERED` will look like this
The slack message when alert is `RESOLVED` will look like this
![Slack](/slack_resolved.png)
![Slack](/documentation/slack_resolved.png)
### Add Alerts to Monitors

View File

@@ -21,7 +21,7 @@ A small text that will be shown below the title.
<div class="border rounded-md">
![Hero Section](/home_1.png)
![Hero Section](/documentation/home_1.png)
</div>
@@ -31,7 +31,7 @@ A small text that will be shown below the title.
You can navigation links to other urls. You can add as many as you want.
![Nav Section](/home_2.png)
![Nav Section](/documentation/home_2.png)
### Icon
@@ -45,7 +45,7 @@ The title of the link.
The URL to redirect to when the link is clicked.
![Nav Section](/home_3.png)
![Nav Section](/documentation/home_3.png)
---

View File

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

View File

@@ -9,7 +9,7 @@ DNS monitors are used to monitor DNS servers. Verify DNS queries for your server
<div class="border rounded-md">
![Monitors Ping](/m_dns.png)
![Monitors Ping](/documentation/m_dns.png)
</div>

40
docs/monitors-group.md Normal file
View File

@@ -0,0 +1,40 @@
---
title: Group Monitors | Kener
description: Learn how to set up and work with Group monitors in kener.
---
# Group Monitors
Group monitors are used to monitor multiple monitors at once. You can use Group monitors to monitor multiple monitors at once and get notified when they are down.
<div class="border rounded-md">
![Monitors Group](/documentation/m_group.png)
</div>
## Timeout
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
The timeout is used to define the time in milliseconds after which the group monitor should timeout.
Let us say the group monitor runs every minute, it will expect in the same minute all the other monitors to finish. It will wait till the timeout for them to complete. If not completed within that timeout, it will be marked as down.
## Monitors
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
You can add as many monitors as you want to monitor. The minimum number of monitors required is 2. The monitor can be any type of monitor.
## Hide
You can hide the monitors that are part of the group monitor. If you hide the monitors, the monitors inside the group will not be shown in the home page.
<div class="note">
The group status will be the worst status of the monitors in the group.
</div>

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ Click on the to add a monitor.
<div class="border rounded-md">
![Monitors Main](/m_main.png)
![Monitors Main](/documentation/m_main.png)
</div>

View File

@@ -19,11 +19,11 @@ Example: `Kener - Open-Source and Modern looking Node.js Status Page for Effortl
```html
<title>
Kener - Open-Source and Modern looking Node.js Status Page for Effortless Incident Management
Kener - Open-Source and Modern looking Node.js Status Page for Effortless Incident Management
</title>
```
![Site Title](/ms_1.png)
![Site Title](/documentation/ms_1.png)
## Site Name
@@ -33,7 +33,7 @@ Example: `Kener - Open-Source and Modern looking Node.js Status Page for Effortl
This will be shown as a brand name on the status page on the nav bar top left.
![Site Name](/s_2.png)
![Site Name](/documentation/s_2.png)
## Home Location

View File

@@ -1,166 +1,166 @@
{
"sidebar": [
{
"sectionTitle": "Getting Started",
"children": [
{
"title": "Introduction",
"link": "/docs/home",
"file": "/home.md"
},
{
"title": "Get Started",
"link": "/docs/quick-start",
"file": "/quick-start.md"
},
{
"title": "Concepts",
"link": "/docs/concepts",
"file": "/concepts.md"
},
"sidebar": [
{
"sectionTitle": "Getting Started",
"children": [
{
"title": "Introduction",
"link": "/docs/home",
"file": "/home.md"
},
{
"title": "Get Started",
"link": "/docs/quick-start",
"file": "/quick-start.md"
},
{
"title": "Concepts",
"link": "/docs/concepts",
"file": "/concepts.md"
},
{
"title": "Deployment",
"link": "/docs/deployment",
"file": "/deployment.md"
},
{
"title": "Databases",
"link": "/docs/database",
"file": "/database.md"
}
]
},
{
"sectionTitle": "Guides",
"children": [
{
"title": "Setup Environment",
"link": "/docs/environment-vars",
"file": "/environment-vars.md"
},
{
"title": "Use Badges",
"link": "/docs/status-badges",
"file": "/status-badges.md"
},
{
"title": "Setup Monitors",
"link": "/docs/monitors",
"file": "/monitors.md"
},
{
"title": "API/Website Monitor",
"link": "/docs/monitors-api",
"file": "/monitors-api.md"
},
{
"title": "Ping Monitor",
"link": "/docs/monitors-ping",
"file": "/monitors-ping.md"
},
{
"title": "TCP Monitor",
"link": "/docs/monitors-tcp",
"file": "/monitors-tcp.md"
},
{
"title": "DNS Monitor",
"link": "/docs/monitors-dns",
"file": "/monitors-dns.md"
},
{
"title": "Setup Triggers",
"link": "/docs/triggers",
"file": "/triggers.md"
},
{
"title": "Setup Site",
"link": "/docs/site",
"file": "/site.md"
},
{
"title": "Setup Github",
"link": "/docs/gh-setup",
"file": "/gh-setup.md"
},
{
"title": "Setup SEO",
"link": "/docs/seo",
"file": "/seo.md"
},
{
"title": "Setup Home",
"link": "/docs/home-page",
"file": "/home-page.md"
},
{
"title": "Setup Theme",
"link": "/docs/theme",
"file": "/theme.md"
},
{
"title": "View Alerts",
"link": "/docs/alerts",
"file": "/alerts.md"
},
{
"title": "API Keys",
"link": "/docs/apikeys",
"file": "/apikeys.md"
},
{
"title": "Deployment",
"link": "/docs/deployment",
"file": "/deployment.md"
},
{
"title": "Databases",
"link": "/docs/database",
"file": "/database.md"
}
]
},
{
"sectionTitle": "Guides",
"children": [
{
"title": "Setup Environment",
"link": "/docs/environment-vars",
"file": "/environment-vars.md"
},
{
"title": "Use Badges",
"link": "/docs/status-badges",
"file": "/status-badges.md"
},
{
"title": "Setup Monitors",
"link": "/docs/monitors",
"file": "/monitors.md"
},
{
"title": "API/Website Monitor",
"link": "/docs/monitors-api",
"file": "/monitors-api.md"
},
{
"title": "Ping Monitor",
"link": "/docs/monitors-ping",
"file": "/monitors-ping.md"
},
{
"title": "TCP Monitor",
"link": "/docs/monitors-tcp",
"file": "/monitors-tcp.md"
},
{
"title": "DNS Monitor",
"link": "/docs/monitors-dns",
"file": "/monitors-dns.md"
},
{
"title": "Group Monitor",
"link": "/docs/monitors-group",
"file": "/monitors-group.md"
},
{
"title": "Setup Triggers",
"link": "/docs/triggers",
"file": "/triggers.md"
},
{
"title": "Setup Site",
"link": "/docs/site",
"file": "/site.md"
},
{
"title": "Setup SEO",
"link": "/docs/seo",
"file": "/seo.md"
},
{
"title": "Setup Home",
"link": "/docs/home-page",
"file": "/home-page.md"
},
{
"title": "Setup Theme",
"link": "/docs/theme",
"file": "/theme.md"
},
{
"title": "View Alerts",
"link": "/docs/alerts",
"file": "/alerts.md"
},
{
"title": "API Keys",
"link": "/docs/apikeys",
"file": "/apikeys.md"
},
{
"title": "Incident Management",
"link": "/docs/incident-management",
"file": "/incident-management.md"
},
{
"title": "Embed",
"link": "/docs/embed",
"file": "/embed.md"
},
{
"title": "Custom JS/CSS",
"link": "/docs/custom-js-css-guide",
"file": "/custom-js-css-guide.md"
},
{
"title": "Internationalization",
"link": "/docs/i18n",
"file": "/i18n.md"
}
]
},
{
"sectionTitle": "API Reference",
"children": [
{
"title": "Kener APIs",
"link": "/docs/kener-apis",
"file": "/kener-apis.md"
}
]
},
{
"sectionTitle": "Help",
"children": [
{
"title": "Fonts",
"link": "/docs/custom-fonts",
"file": "/custom-fonts.md"
},
{
"title": "Changelogs",
"link": "/docs/changelogs",
"file": "/changelogs.md"
},
{
"title": "Roadmap",
"link": "/docs/roadmap",
"file": "/roadmap.md"
}
]
}
]
{
"title": "Incident Management",
"link": "/docs/incident-management",
"file": "/incident-management.md"
},
{
"title": "Embed",
"link": "/docs/embed",
"file": "/embed.md"
},
{
"title": "Custom JS/CSS",
"link": "/docs/custom-js-css-guide",
"file": "/custom-js-css-guide.md"
},
{
"title": "Internationalization",
"link": "/docs/i18n",
"file": "/i18n.md"
}
]
},
{
"sectionTitle": "API Reference",
"children": [
{
"title": "Kener APIs",
"link": "/docs/kener-apis",
"file": "/kener-apis.md"
}
]
},
{
"sectionTitle": "Help",
"children": [
{
"title": "Fonts",
"link": "/docs/custom-fonts",
"file": "/custom-fonts.md"
},
{
"title": "Changelogs",
"link": "/docs/changelogs",
"file": "/changelogs.md"
},
{
"title": "Roadmap",
"link": "/docs/roadmap",
"file": "/roadmap.md"
}
]
}
]
}

View File

@@ -11,7 +11,7 @@ Kener provides you with the ability to customize the theme of your status page.
## Home Page Pattern
Kener can show a subtle pattern in all your pages. It is either sqaure or dots. Right now you cannot modify the color of the pattern. However, you can disable it by choosing none
Kener can show a subtle pattern in all your pages. It is either square or dots. Right now you cannot modify the color of the pattern. However, you can disable it by choosing none
---
@@ -35,13 +35,13 @@ You can change how the bars and summary of a monitor looks like.
The status bar will be a gradient from green to red/yellow based on the status of the monitor.
![Trigger API](/x1.png)
![Trigger API](/documentation/x1.png)
#### Full
The status bar will be a solid color based on the status of the monitor.
![Trigger API](/x2.png)
![Trigger API](/documentation/x2.png)
---
@@ -51,11 +51,11 @@ Adjust the roundness of the status bar.
#### SHARP
![Trigger API](/x4.png)
![Trigger API](/documentation/x4.png)
#### ROUNDED
![Trigger API](/x3.png)
![Trigger API](/documentation/x3.png)
---
@@ -97,7 +97,7 @@ You can add custom CSS to your status page. This will be added to the head of th
```css
.my-class {
color: red;
color: red;
}
```

View File

@@ -9,7 +9,7 @@ Triggers are used to trigger actions based on the status of your monitors. You c
<div class="border rounded-md">
![Trigger API](/trig_1.png)
![Trigger API](/documentation/trig_1.png)
</div>
@@ -38,7 +38,7 @@ Webhook triggers are used to send a HTTP POST request to a URL when a monitor go
<div class="border rounded-md">
![Trigger API](/trig_web.png)
![Trigger API](/documentation/trig_web.png)
</div>
@@ -72,24 +72,24 @@ Body of the webhook will be sent as below:
```json
{
"id": "mockoon-9",
"alert_name": "Mockoon DOWN",
"severity": "critical",
"status": "TRIGGERED",
"source": "Kener",
"timestamp": "2024-11-27T04:55:00.369Z",
"description": "🚨 **Service Alert**: Check the details below",
"details": {
"metric": "Mockoon",
"current_value": 1,
"threshold": 1
},
"actions": [
{
"text": "View Monitor",
"url": "https://kener.ing/monitor-mockoon"
}
]
"id": "mockoon-9",
"alert_name": "Mockoon DOWN",
"severity": "critical",
"status": "TRIGGERED",
"source": "Kener",
"timestamp": "2024-11-27T04:55:00.369Z",
"description": "🚨 **Service Alert**: Check the details below",
"details": {
"metric": "Mockoon",
"current_value": 1,
"threshold": 1
},
"actions": [
{
"text": "View Monitor",
"url": "https://kener.ing/monitor-mockoon"
}
]
}
```
@@ -118,7 +118,7 @@ Discord triggers are used to send a message to a discord channel when a monitor
<div class="border rounded-md">
![Trigger API](/trig_2.png)
![Trigger API](/documentation/trig_2.png)
</div>
@@ -147,7 +147,7 @@ The discord message when alert is `TRIGGERED` will look like this
The discord message when alert is `RESOLVED` will look like this
![Discord](/discord_resolved.png)
![Discord](/documentation/discord_resolved.png)
## Slack
@@ -155,7 +155,7 @@ Slack triggers are used to send a message to a slack channel when a monitor goes
<div class="border rounded-md">
![Trigger API](/trig_3.png)
![Trigger API](/documentation/trig_3.png)
</div>
@@ -185,7 +185,7 @@ The slack message when alert is `TRIGGERED` will look like this
The slack message when alert is `RESOLVED` will look like this
![Slack](/slack_resolved.png)
![Slack](/documentation/slack_resolved.png)
## Email
@@ -193,7 +193,7 @@ Email triggers are used to send an email when a monitor goes down or up. Kener s
<div class="border rounded-md">
![Trigger API](/trig_4.png)
![Trigger API](/documentation/trig_4.png)
</div>
@@ -250,11 +250,11 @@ Subject of the email when `RESOLVED`
The emaik message when alert is `TRIGGERED` will look like this
![Slack](/em_t.png)
![Slack](/documentation/em_t.png)
The emaik message when alert is `RESOLVED` will look like this
![Slack](/em_r.png)
![Slack](/documentation/em_r.png)
---
@@ -278,9 +278,9 @@ Set the URL to `https://api.telegram.org/bot[BOT_TOKEN]/sendMessage`. Replace [B
```json
{
"chat_id": "[CHAT_ID]", // Replace [CHAT_ID] with your chat id
"text": "<b>${alert_name}</b>\n\n<b>Severity:</b> <code>${severity}</code>\n<b>Status:</b> ${status}\n<b>Source:</b> Kener\n<b>Time:</b> ${timestamp}\n\n📌 <b>Details:</b>\n- <b>Metric:</b>${metric}\n- <b>Current Value:</b> <code>${current_value}</code>\n- <b>Threshold:</b> <code>${threshold}</code>\n\n🔍 <a href=\"${action_url}\">${action_text}</a>",
"parse_mode": "HTML"
"chat_id": "[CHAT_ID]", // Replace [CHAT_ID] with your chat id
"text": "<b>${alert_name}</b>\n\n<b>Severity:</b> <code>${severity}</code>\n<b>Status:</b> ${status}\n<b>Source:</b> Kener\n<b>Time:</b> ${timestamp}\n\n📌 <b>Details:</b>\n- <b>Metric:</b>${metric}\n- <b>Current Value:</b> <code>${current_value}</code>\n- <b>Threshold:</b> <code>${threshold}</code>\n\n🔍 <a href=\"${action_url}\">${action_text}</a>",
"parse_mode": "HTML"
}
```

View File

@@ -1,6 +1,6 @@
{
"name": "kener",
"version": "3.1.0",
"version": "3.1.2",
"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.",

View File

@@ -1,69 +1,82 @@
code:not([class^="language-"]) {
@apply rounded bg-gray-100 px-1.5 py-0.5 font-mono text-xs dark:bg-gray-800;
@apply rounded bg-gray-100 px-1.5 py-0.5 font-mono text-xs dark:bg-gray-800;
}
.sidebar-item.active,
.sidebar-item:hover {
color: #ed702d;
color: #ed702d;
}
.w-585px {
width: 585px;
width: 585px;
}
main {
scroll-behavior: smooth;
scroll-behavior: smooth;
}
.kener-home-links a {
text-decoration: none;
background-color: var(--bg-background);
text-decoration: none;
background-color: var(--bg-background);
}
.kener-home-links > div:hover {
transition: all 0.3s;
box-shadow: 0 0 8px 1.5px #3e9a4b;
transition: all 0.3s;
box-shadow: 0 0 8px 1.5px #3e9a4b;
}
.accm input:checked ~ div {
display: block;
display: block;
}
.accm input ~ div {
display: none;
display: none;
}
.accm input {
visibility: hidden;
visibility: hidden;
}
.accmt {
background-color: hsl(223, 10%, 14%);
background-color: hsl(223, 10%, 14%);
}
.accm input:checked ~ .showaccm span:first-child {
display: none;
display: none;
}
.accm input:checked ~ .showaccm span:last-child {
display: block;
display: block;
}
.accm input ~ .showaccm span:first-child {
display: block;
display: block;
}
.accm input ~ .showaccm span:last-child {
display: none;
display: none;
}
.hljs {
background: transparent !important;
background: transparent !important;
}
.note {
@apply mt-4 rounded-md border bg-background p-3 text-sm shadow-sm;
@apply mt-4 rounded-md border bg-background p-3 text-sm shadow-sm;
}
.note.danger {
border: 1px solid #e3342f;
color: #e3342f;
border: 1px solid #e3342f;
color: #e3342f;
}
.note.info {
border: 1px solid #3490dc;
color: #3490dc;
border: 1px solid #3490dc;
color: #3490dc;
}
.copybtn .copy-btn {
transform: scale(1);
}
.copybtn .check-btn {
transform: scale(0);
}
.copybtn:focus .copy-btn {
transform: scale(0);
}
.copybtn:focus .check-btn {
transform: scale(1);
}

View File

@@ -36,6 +36,7 @@
let formState = "idle";
export let newMonitor;
if (!!newMonitor && newMonitor.monitor_type == "PING" && !!!newMonitor.pingConfig.hosts) {
newMonitor.pingConfig.hosts = [];
}
@@ -250,17 +251,34 @@
invalidFormMessage = "Invalid Name Server";
return;
}
//atleast one value should be present
//at least one value should be present
if (
!newMonitor.dnsConfig.values ||
!Array.isArray(newMonitor.dnsConfig.values) ||
newMonitor.dnsConfig.values.length === 0
) {
invalidFormMessage = "Atleast one value is required";
invalidFormMessage = "At least one value is required";
return;
}
newMonitor.type_data = JSON.stringify(newMonitor.dnsConfig);
} else if (newMonitor.monitor_type === "GROUP") {
let selectedOnly = newMonitor.groupConfig.monitors.filter((monitor) => monitor.selected);
if (selectedOnly.length < 2) {
invalidFormMessage = "Select at least 2 monitors";
return;
}
//timeout >=1000
if (newMonitor.groupConfig.timeout < 1000) {
invalidFormMessage = "Timeout should be greater than or equal to 1000";
return;
}
newMonitor.type_data = JSON.stringify({
monitors: selectedOnly,
timeout: parseInt(newMonitor.groupConfig.timeout),
hideMonitors: newMonitor.groupConfig.hideMonitors
});
}
formState = "loading";
try {
@@ -385,7 +403,7 @@
id="logo"
type="file"
disabled={loadingLogo}
accept=".jpg, .jpeg, .png"
accept=".jpg, .jpeg, .png, .svg"
on:change={(e) => {
handleFileChangeLogo(e);
}}
@@ -504,6 +522,7 @@
<Select.Item value="PING" label="PING" class="text-sm font-medium">PING</Select.Item>
<Select.Item value="DNS" label="DNS" class="text-sm font-medium">DNS</Select.Item>
<Select.Item value="TCP" label="TCP" class="text-sm font-medium">TCP</Select.Item>
<Select.Item value="GROUP" label="GROUP" class="text-sm font-medium">GROUP</Select.Item>
</Select.Group>
</Select.Content>
</Select.Root>
@@ -868,6 +887,54 @@
</div>
</div>
</div>
{:else if newMonitor.monitor_type == "GROUP"}
<div class="mt-4 grid grid-cols-6 gap-2">
<div class="col-span-6 mb-1">
<Label for="timeout">
Timeout(ms)
<span class="text-red-500">*</span>
</Label>
<Input bind:value={newMonitor.groupConfig.timeout} class="w-40" id="timeout" />
<p class="my-1 text-xs text-muted-foreground">
Maximum Time in milliseconds it will wait for all the monitors to resolved in that particular timestamp
</p>
</div>
<div class="col-span-6">
<Label class="text-sm">Monitors</Label>
</div>
{#each newMonitor.groupConfig.monitors as monitor}
<div class="col-span-3">
<label class="cursor-pointer">
<input
type="checkbox"
on:change={(e) => {
monitor.selected = e.target.checked;
}}
checked={monitor.selected}
/>
{monitor.name}
<span class="text-muted-foreground">({monitor.tag})</span>
</label>
</div>
{/each}
<div class="col-span-6 mt-2 rounded-sm border p-2">
<label class="cursor-pointer">
<input
type="checkbox"
on:change={(e) => {
newMonitor.groupConfig.hideMonitors = e.target.checked;
}}
checked={newMonitor.groupConfig.hideMonitors}
/>
Hide Grouped Monitors from Home View
</label>
</div>
<div class="col-span-6 mt-2">
<ul class="text-xs font-medium leading-5 text-muted-foreground">
<li>- The group status will be the worst status of the monitors in the group.</li>
</ul>
</div>
</div>
{/if}
</div>
<div class="absolute bottom-0 grid h-16 w-full grid-cols-6 justify-end gap-2 border-t p-3 pr-6">

View File

@@ -32,7 +32,7 @@
//broadcast a custom event named blockScroll
if (!!isMounted) {
const noScrollEvent = new CustomEvent("noScroll", {
detail: showAddMonitor
detail: showAddMonitor || draggableMenu || shareMenusToggle
});
window.dispatchEvent(noScrollEvent);
@@ -87,10 +87,29 @@
nameServer: "8.8.8.8",
matchType: "ANY",
values: []
}
},
groupConfig: createGroupConfig({
monitors: [],
timeout: 10000,
hideMonitors: false
})
};
}
function createGroupConfig(groupConfig) {
let eligibleMonitors = monitors
.filter((m) => m.monitor_type != "GROUP" && m.status == "ACTIVE")
.map((m) => {
let isSelected = false;
if (groupConfig.monitors) {
isSelected = !!groupConfig.monitors.find((tm) => tm.id == m.id);
}
return { id: m.id, name: m.name, tag: m.tag, selected: isSelected };
});
return { monitors: eligibleMonitors, timeout: groupConfig.timeout, hideMonitors: groupConfig.hideMonitors };
}
function showUpdateMonitorSheet(m) {
resetNewMonitor();
newMonitor = { ...newMonitor, ...m };
@@ -103,6 +122,8 @@
newMonitor.dnsConfig = JSON.parse(newMonitor.type_data);
} else if (newMonitor.monitor_type == "TCP") {
newMonitor.tcpConfig = JSON.parse(newMonitor.type_data);
} else if (newMonitor.monitor_type == "GROUP") {
newMonitor.groupConfig = createGroupConfig(JSON.parse(newMonitor.type_data));
}
showAddMonitor = true;
}
@@ -142,7 +163,7 @@
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ action: "getTriggers", data: { status: "ACTIVE" } })
body: JSON.stringify({ action: "getTriggers", data: {} })
});
triggers = await apiResp.json();
} catch (error) {
@@ -439,7 +460,13 @@
</DropdownMenu.Content>
</DropdownMenu.Root>
<Button variant="secondary" class="h-8 w-8 p-2 " on:click={() => openAlertMenu(monitor)}>
<Button
variant="secondary"
class="h-8 w-8 p-2 {monitor.down_trigger?.active || monitor.degraded_trigger?.active
? 'text-yellow-500'
: ''}"
on:click={() => openAlertMenu(monitor)}
>
<Bell class="inline h-4 w-4" />
</Button>
<Button variant="secondary" class="h-8 w-8 p-2" href="#{monitor.tag}">
@@ -596,7 +623,7 @@
<p class="col-span-4 mt-2 text-sm font-medium">Choose Triggers</p>
{#each triggers as trigger}
<div class="col-span-1 overflow-hidden overflow-ellipsis whitespace-nowrap">
<label class="cursor-pointer">
<label class="cursor-pointer" class:line-through={trigger.trigger_status != "ACTIVE"}>
<input
type="checkbox"
class="text-sm"

View File

@@ -8,18 +8,7 @@
import GMI from "$lib/components/gmi.svelte";
import { page } from "$app/stores";
import {
Share2,
Link,
CopyCheck,
Code,
Settings,
TrendingUp,
Percent,
Loader,
ChevronLeft,
ChevronRight
} from "lucide-svelte";
import { Share2, ArrowRight, Settings, TrendingUp, Loader, ChevronLeft, ChevronRight } from "lucide-svelte";
import { buttonVariants } from "$lib/components/ui/button";
import { createEventDispatcher } from "svelte";
import { afterUpdate } from "svelte";
@@ -33,7 +22,6 @@
const dispatch = createEventDispatcher();
export let monitor;
export let localTz;
export let lang;
export let embed = false;
@@ -224,6 +212,16 @@
>
<Share2 class="h-4 w-4 " />
</Button>
{#if monitor.monitor_type === "GROUP"}
<Button
class="bounce-right h-5 p-0 text-muted-foreground hover:text-primary"
variant="link"
href="{base}?group={monitor.tag}"
rel="external"
>
<ArrowRight class="arrow h-4 w-4" />
</Button>
{/if}
</div>
</div>
</div>

View File

@@ -27,7 +27,7 @@ const alertingQueue = new Queue({
});
const apiQueue = new Queue({
concurrency: 10, // Number of tasks that can run concurrently
timeout: 10000, // Timeout in ms after which a task will be considered as failed (optional)
timeout: 2 * 60 * 1000, // Timeout in ms after which a task will be considered as failed (optional)
autostart: true, // Automatically start the queue (optional)
});
@@ -75,29 +75,6 @@ async function manualIncident(monitor) {
return manualData;
}
const tcpCall = async (hosts, tcpEval, tag) => {
let arrayOfPings = [];
for (let i = 0; i < hosts.length; i++) {
const host = hosts[i];
arrayOfPings.push(await TCP(host.type, host.host, host.port, host.timeout));
}
let respBase64 = Buffer.from(JSON.stringify(arrayOfPings)).toString("base64");
let evalResp = undefined;
try {
evalResp = await eval(tcpEval + `("${respBase64}")`);
} catch (error) {
console.log(`Error in tcpEval for ${tag}`, error.message);
}
//reduce to get the status
return {
status: evalResp.status,
latency: evalResp.latency,
type: REALTIME,
};
};
const Minuter = async (monitor) => {
let realTimeData = {};
let manualData = {};
@@ -132,6 +109,8 @@ const Minuter = async (monitor) => {
realTimeData[startOfMinute] = await serviceClient.execute();
} else if (monitor.monitor_type === "DNS") {
realTimeData[startOfMinute] = await serviceClient.execute();
} else if (monitor.monitor_type === "GROUP") {
realTimeData[startOfMinute] = await serviceClient.execute(startOfMinute);
}
manualData = await manualIncident(monitor);

View File

@@ -79,6 +79,30 @@ class DbImpl {
return data;
}
async getLastStatusBeforeCombined(monitor_tags_arr, timestamp) {
//monitor_tag_arr is an array of string
return await this.knex("monitoring_data")
.select(
"timestamp",
this.knex.raw("COUNT(*) as total_entries"),
this.knex.raw("AVG(latency) as latency"),
this.knex.raw(`
CASE
WHEN SUM(CASE WHEN status = 'DOWN' THEN 1 ELSE 0 END) > 0 THEN 'DOWN'
WHEN SUM(CASE WHEN status = 'DEGRADED' THEN 1 ELSE 0 END) > 0 THEN 'DEGRADED'
ELSE 'UP'
END as status
`),
)
.whereIn("monitor_tag", monitor_tags_arr)
.where("timestamp", "=", timestamp)
.groupBy("timestamp")
.having("total_entries", "=", monitor_tags_arr.length)
.orderBy("timestamp", "desc")
.limit(1)
.first();
}
async background() {
//clear data older than 90 days
let ninetyDaysAgo = GetMinuteStartNowTimestampUTC() - 86400 * 100;
@@ -272,6 +296,14 @@ class DbImpl {
if (!!data.id) {
query = query.andWhere("id", data.id);
}
if (!!data.tag) {
query = query.andWhere("tag", data.tag);
}
if (!!data.tags) {
query = query.andWhere((builder) => {
builder.whereIn("tag", data.tags);
});
}
return await query.orderBy("id", "desc");
}
@@ -307,7 +339,14 @@ class DbImpl {
//get all alerts with given status
async getTriggers(data) {
return await this.knex("triggers").where("trigger_status", data.status).orderBy("id", "desc");
let query = this.knex("triggers").whereRaw("1=1");
if (!!data.status) {
query = query.andWhere("trigger_status", data.status);
}
if (!!data.id) {
query = query.andWhere("id", data.id);
}
return await query.orderBy("id", "desc");
}
//get trigger by id

View File

@@ -0,0 +1,37 @@
// @ts-nocheck
import axios from "axios";
import { GetRequiredSecrets, ReplaceAllOccurrences, Wait } from "../tool.js";
import { UP, DOWN, DEGRADED, REALTIME, TIMEOUT, ERROR, MANUAL } from "../constants.js";
import db from "../db/db.js";
async function waitForDataAndReturn(arr, ts, d, maxTime) {
await Wait(d);
let data = await db.getLastStatusBeforeCombined(arr, ts);
if (data) {
data.type = REALTIME;
return data;
} else if (d > maxTime) {
return {
status: DOWN,
latency: 0,
type: TIMEOUT,
};
} else {
return await waitForDataAndReturn(arr, ts, d * 2, maxTime);
}
}
class GroupCall {
monitor;
constructor(monitor, timestamp) {
this.monitor = monitor;
}
async execute(startOfMinute) {
let tagArr = this.monitor.type_data.monitors.map((m) => m.tag);
return await waitForDataAndReturn(tagArr, startOfMinute, 500, this.monitor.type_data.timeout);
}
}
export default GroupCall;

View File

@@ -3,6 +3,7 @@ import ApiCall from "./apiCall.js";
import PingCall from "./pingCall.js";
import TcpCall from "./tcpCall.js";
import DnsCall from "./dnsCall.js";
import GroupCall from "./groupCall.js";
class Service {
service;
@@ -18,13 +19,15 @@ class Service {
this.service = new DnsCall(monitor);
} else if (monitor.monitor_type === "NONE") {
this.service = null;
} else if (monitor.monitor_type === "GROUP") {
this.service = new GroupCall(monitor);
} else {
console.log("Invalid monitor.monitor_type ", monitor.monitor_type);
process.exit(1);
}
}
async execute() {
return await this.service.execute();
async execute(...p) {
return await this.service.execute(...p);
}
}

View File

@@ -14,68 +14,68 @@ const jobs = [];
process.env.TZ = "UTC";
const jobSchedule = async () => {
const activeMonitors = (await GetMonitorsParsed({ status: "ACTIVE" })).map((m) => {
return {
...m,
hash: "Tag: " + m.tag + " Hash:" + HashString(JSON.stringify(m))
};
});
const activeMonitors = (await GetMonitorsParsed({ status: "ACTIVE" })).map((m) => {
return {
...m,
hash: "Tag: " + m.tag + " Hash:" + HashString(JSON.stringify(m)),
};
});
//stop and remove jobs not present in activeMonitors
for (let i = 0; i < jobs.length; i++) {
const job = jobs[i];
const monitor = activeMonitors.find((m) => m.hash === job.name);
if (!monitor) {
job.stop();
jobs.splice(i, 1);
i--;
console.log("REMOVING JOB WITH NAME " + job.name);
}
}
//stop and remove jobs not present in activeMonitors
for (let i = 0; i < jobs.length; i++) {
const job = jobs[i];
const monitor = activeMonitors.find((m) => m.hash === job.name);
if (!monitor) {
job.stop();
jobs.splice(i, 1);
i--;
console.log("REMOVING JOB WITH NAME " + job.name);
}
}
//add new jobs from activeMonitors if not present already in jobs
for (let i = 0; i < activeMonitors.length; i++) {
const monitor = activeMonitors[i];
const job = jobs.find((j) => j.name === monitor.hash);
if (!job) {
const j = Cron(
monitor.cron,
async () => {
await Minuter(monitor);
},
{
protect: true,
name: monitor.hash
}
);
console.log("ADDING NEW JOB WITH NAME " + j.name);
j.trigger();
jobs.push(j);
}
}
//add new jobs from activeMonitors if not present already in jobs
for (let i = 0; i < activeMonitors.length; i++) {
const monitor = activeMonitors[i];
const job = jobs.find((j) => j.name === monitor.hash);
if (!job) {
const j = Cron(
monitor.cron,
async () => {
await Minuter(monitor);
},
{
protect: true,
name: monitor.hash,
},
);
console.log("ADDING NEW JOB WITH NAME " + j.name);
j.trigger();
jobs.push(j);
}
}
};
async function Startup() {
const mainJob = Cron(
"* * * * *",
async () => {
await jobSchedule();
},
{
protect: true,
name: "Main Job"
}
);
const mainJob = Cron(
"* * * * *",
async () => {
await jobSchedule();
},
{
protect: true,
name: "Main Job",
},
);
mainJob.trigger();
mainJob.trigger();
figlet("Kener is UP!", function (err, data) {
if (err) {
console.log("Something went wrong...");
return;
}
console.log(data);
});
figlet("Kener is UP!", function (err, data) {
if (err) {
console.log("Something went wrong...");
return;
}
console.log(data);
});
}
// Call Startup() if not imported as a module
@@ -83,7 +83,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
if (process.argv[1] === __filename) {
Startup();
Startup();
}
export default Startup;

View File

@@ -4,6 +4,9 @@
import "../../docs.css";
import { Button } from "$lib/components/ui/button";
import Sun from "lucide-svelte/icons/sun";
import Menu from "lucide-svelte/icons/menu";
import X from "lucide-svelte/icons/x";
import { clickOutsideAction, slide } from "svelte-legos";
import Moon from "lucide-svelte/icons/moon";
import { onMount } from "svelte";
import { base } from "$app/paths";
@@ -44,9 +47,15 @@
tableOfContents = e.detail.rightbar;
}
}
let sideBarHidden = true;
//if desktop show sidebar by default
let isMounted = false;
onMount(() => {
setTheme();
if (window.innerWidth > 768) {
sideBarHidden = false;
}
});
</script>
@@ -74,7 +83,7 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
</svelte:head>
<div class="dark">
<nav class="z-2 fixed left-0 right-0 top-0 z-30 h-16 bg-card">
<nav class="fixed left-0 right-0 top-0 z-40 h-16 bg-card">
<div class="mx-auto h-full border-b bg-card px-4 sm:px-6 lg:px-8">
<div class="flex h-full items-center justify-between">
<!-- Logo/Brand -->
@@ -83,7 +92,7 @@
<!-- Document Icon - Replace with your own logo -->
<img src="https://kener.ing/logo.png" class="h-8 w-8" alt="" />
<span class="text-xl font-medium">Kener Documentation</span>
<span class="me-2 rounded border px-2.5 py-0.5 text-xs font-medium"> 3.1.0 </span>
<span class="me-2 rounded border px-2.5 py-0.5 text-xs font-medium"> 3.1.2 </span>
</a>
</div>
@@ -104,10 +113,18 @@
<!-- Mobile Menu Button -->
<div class="md:hidden">
<button type="button" class="hover: text-muted-foreground">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<button
type="button"
class="mt-2 hover:text-muted-foreground"
on:click={() => {
sideBarHidden = !sideBarHidden;
}}
>
{#if sideBarHidden}
<Menu class="h-6 w-6" />
{:else}
<X class="h-6 w-6" />
{/if}
</button>
</div>
</div>
@@ -115,34 +132,40 @@
</nav>
<!-- Sidebar -->
<aside class="z-2 fixed bottom-0 left-0 top-16 w-72 overflow-y-auto">
<nav class="border-r bg-card p-6">
<!-- Getting Started Section -->
{#each sidebar as item}
<div class="mb-4">
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{item.sectionTitle}
</h3>
<div class="">
{#each item.children as child}
<a
href={child.link.startsWith("/") ? base + child.link : child.link}
class="sidebar-item group flex items-center rounded-md px-3 py-2 text-sm font-medium {!!child.active
? 'active'
: ''}"
>
{child.title}
</a>
{/each}
{#if !sideBarHidden}
<aside
transition:slide={{ direction: "left", duration: 200 }}
class="fixed bottom-0 left-0 top-16 z-30 w-72 overflow-y-auto md:block"
class:hidden={sideBarHidden}
>
<nav class="border-r bg-card p-6">
<!-- Getting Started Section -->
{#each sidebar as item}
<div class="mb-4">
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{item.sectionTitle}
</h3>
<div class="">
{#each item.children as child}
<a
href={child.link.startsWith("/") ? base + child.link : child.link}
class="sidebar-item group flex items-center rounded-md px-3 py-2 text-sm font-medium {!!child.active
? 'active'
: ''}"
>
{child.title}
</a>
{/each}
</div>
</div>
</div>
{/each}
</nav>
</aside>
{/each}
</nav>
</aside>
{/if}
<!-- Main Content -->
<main class="z-2 dark relative ml-72 min-h-screen pt-16">
<div class="mx-auto max-w-5xl px-4 py-10 sm:px-6 lg:px-8 lg:pr-64">
<main class="dark relative z-10 min-h-screen pt-16 md:ml-72">
<div class="mx-auto max-w-5xl px-4 py-10 sm:px-6 md:px-8 md:pr-64">
<!-- Content Header -->
<div
class="prose prose-stone max-w-none dark:prose-invert prose-code:rounded prose-code:py-[0.2rem] prose-code:font-mono prose-code:text-sm prose-code:font-normal prose-pre:bg-opacity-0 dark:prose-pre:bg-neutral-900"
@@ -152,7 +175,7 @@
</div>
</main>
{#if tableOfContents.length > 0}
<div class="blurry-bg fixed bottom-0 right-0 top-16 hidden w-64 overflow-y-auto px-6 py-10 lg:block">
<div class="blurry-bg fixed bottom-0 right-0 top-16 z-50 hidden w-64 overflow-y-auto px-6 py-10 lg:block">
<h4 class="mb-3 text-sm font-semibold uppercase tracking-wider">On this page</h4>
<nav class="space-y-2">
{#each tableOfContents as item}

View File

@@ -1,63 +1,153 @@
<script>
import { onMount } from "svelte";
import { afterNavigate } from "$app/navigation";
export let data;
let subHeadings = [];
let previousH2 = null;
function fillSubHeadings() {
subHeadings = [];
const headings = document.querySelectorAll("#markdown h2, #markdown h3");
headings.forEach((heading) => {
let id = heading.textContent.replace(/[^a-z0-9]/gi, "-").toLowerCase();
if (heading.tagName === "H2") {
previousH2 = id;
} else {
id = `${previousH2}-${id}`;
}
heading.id = id;
subHeadings.push({
id,
text: heading.textContent,
level: heading.tagName === "H2" ? 2 : 3
});
});
}
import { onMount } from "svelte";
import { Button } from "$lib/components/ui/button";
import { ArrowLeft, ArrowRight } from "lucide-svelte";
import { afterNavigate } from "$app/navigation";
export let data;
let subHeadings = [];
let previousH2 = null;
let subPath = data.docFilePath.split(".md")[0];
function fillSubHeadings() {
subHeadings = [];
const headings = document.querySelectorAll("#markdown h2, #markdown h3");
headings.forEach((heading) => {
let id = heading.textContent.replace(/[^a-z0-9]/gi, "-").toLowerCase();
if (heading.tagName === "H2") {
previousH2 = id;
} else {
id = `${previousH2}-${id}`;
}
heading.id = id;
subHeadings.push({
id,
text: heading.textContent,
level: heading.tagName === "H2" ? 2 : 3
});
});
}
afterNavigate(() => {
document.dispatchEvent(
new CustomEvent("pagechange", {
bubbles: true,
detail: {
docFilePath: data.docFilePath
}
})
);
fillSubHeadings();
document.dispatchEvent(
new CustomEvent("rightbar", {
bubbles: true,
detail: {
rightbar: subHeadings
}
})
);
hljs.highlightAll();
});
onMount(async () => {});
let nextPath;
let previousPath;
function scrollToId(id) {
const element = document.getElementById(id);
if (element) {
const y = element.getBoundingClientRect().top + window.pageYOffset - 100;
window.scrollTo({ top: y, behavior: "smooth" });
}
}
//function to iterate all h1,h2 nd h3 and add a button inside them
function addCopyButton() {
const headings = document.querySelectorAll("#markdown h1, #markdown h2, #markdown h3");
headings.forEach((heading) => {
const button = document.createElement("button");
button.classList.add("copybtn");
button.classList.add("relative");
button.innerHTML = `<svg class="copy-btn left-0 top-0 absolute" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#777" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
<svg class="check-btn absolute left-0 top-0" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="green" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check"><path d="M20 6 9 17l-5-5"/></svg>`;
button.style = "margin-left: 10px;height:16px;width:16px background-color: transparent; cursor: pointer;";
button.onclick = () => {
navigator.clipboard.writeText(`https://kener.ing/docs${subPath}#` + heading.id);
};
heading.appendChild(button);
});
}
function handleRightBarClick(e) {
if (e.target.tagName === "A") {
e.preventDefault();
const id = e.target.getAttribute("href").slice(1);
scrollToId(id);
}
}
//function to find next and previous path
function findNextAndPreviousPath() {
let structure = data.siteStructure.sidebar;
for (let i = 0; i < structure.length; i++) {
let children = structure[i].children;
for (let j = 0; j < children.length; j++) {
if (children[j].file === data.docFilePath) {
if (j > 0) {
previousPath = children[j - 1];
}
if (j < children.length - 1) {
nextPath = children[j + 1];
}
break;
}
}
}
}
afterNavigate(() => {
document.dispatchEvent(
new CustomEvent("pagechange", {
bubbles: true,
detail: {
docFilePath: data.docFilePath
}
})
);
fillSubHeadings();
document.dispatchEvent(
new CustomEvent("rightbar", {
bubbles: true,
detail: {
rightbar: subHeadings
}
})
);
hljs.highlightAll();
//if hash present scroll to hash
if (location.hash) {
scrollToId(location.hash.slice(1));
}
//if hash change scroll
window.addEventListener("hashchange", () => {
scrollToId(location.hash.slice(1));
});
window.addEventListener("click", (e) => {
handleRightBarClick(e);
});
addCopyButton();
findNextAndPreviousPath();
});
onMount(async () => {});
</script>
<svelte:head>
<title>{data.title}</title>
<meta name="description" content={data.description} />
<link rel="canonical" href="https://kener.ing/docs{data.docFilePath.split('.md')[0]}" />
<title>{data.title}</title>
<meta name="description" content={data.description} />
<link rel="canonical" href="https://kener.ing/docs{subPath}" />
</svelte:head>
<div id="markdown">
{@html data.md}
{@html data.md}
</div>
<div class="mt-4 grid grid-cols-2">
<div class="col-span-1">
{#if previousPath}
<Button variant="outline" class="no-underline" href={previousPath.link}>
<ArrowLeft class="mr-2 h-4 w-4" />
<span>{previousPath.title}</span>
</Button>
{/if}
</div>
<div class="col-span-1 flex justify-end">
{#if nextPath}
<Button variant="outline" class="no-underline" href={nextPath.link}>
<span>{nextPath.title}</span>
<ArrowRight class="ml-2 h-4 w-4" />
</Button>
{/if}
</div>
</div>
<style>
#markdown {
scroll-behavior: smooth;
}
#markdown {
scroll-behavior: smooth;
}
</style>

View File

@@ -7,17 +7,77 @@ function removeTags(str) {
if (str === null || str === "") return false;
else str = str.toString();
// Regular expression to identify HTML tags in
// the input string. Replacing the identified
// HTML tag with a null string.
return str.replace(/(<([^>]+)>)/gi, "");
}
export async function load({ parent, url }) {
let monitors = await GetMonitors({ status: "ACTIVE" });
async function returnTypeOfMonitorsPageMeta(url) {
const query = url.searchParams;
const requiredCategory = query.get("category") || "Home";
let filter = {
status: "ACTIVE",
};
let pageType = "home";
let group;
if (!!query.get("category") && query.get("category") !== "Home") {
filter.category_name = query.get("category");
pageType = "category";
}
if (!!query.get("monitor")) {
filter.tag = query.get("monitor");
pageType = "monitor";
}
if (!!query.get("group")) {
let g = await GetMonitors({ status: "ACTIVE", tag: query.get("group") });
if (g.length > 0) {
group = g[0];
let typeData = JSON.parse(g[0].type_data);
let groupPresentMonitorTags = typeData.monitors.map((monitor) => monitor.tag);
filter.tags = groupPresentMonitorTags;
pageType = "group";
}
}
let monitors = await GetMonitors(filter);
return { monitors, pageType, group };
}
export async function load({ parent, url }) {
const query = url.searchParams;
let { monitors, pageType, group } = await returnTypeOfMonitorsPageMeta(url);
let hiddenGroupedMonitorsTags = [];
for (let i = 0; i < monitors.length; i++) {
if (pageType === "home" && monitors[i].monitor_type === "GROUP") {
let typeData = JSON.parse(monitors[i].type_data);
if (typeData.monitors && typeData.hideMonitors) {
hiddenGroupedMonitorsTags = hiddenGroupedMonitorsTags.concat(typeData.monitors.map((monitor) => monitor.tag));
}
}
}
monitors = monitors
.map((monitor) => {
return {
tag: monitor.tag,
name: monitor.name,
description: monitor.description,
monitor_type: monitor.monitor_type,
image: monitor.image,
category_name: monitor.category_name,
day_degraded_minimum_count: monitor.day_degraded_minimum_count,
day_down_minimum_count: monitor.day_down_minimum_count,
id: monitor.id,
image: monitor.image,
include_degraded_in_downtime: monitor.include_degraded_in_downtime,
};
})
.filter((monitor) => !hiddenGroupedMonitorsTags.includes(monitor.tag));
const parentData = await parent();
const siteData = parentData.site;
let hero = siteData.hero;
let pageTitle = siteData.title;
let canonical = siteData.siteURL;
let pageDescription = "";
@@ -25,48 +85,66 @@ export async function load({ parent, url }) {
if (descDb) {
pageDescription = descDb.value;
}
monitors = SortMonitor(siteData.monitorSort, monitors);
const monitorsActive = [];
for (let i = 0; i < monitors.length; i++) {
//only return monitors that have category as home or category is not present
if (!!!query.get("monitor") && !!monitors[i].category_name && monitors[i].category_name !== requiredCategory) {
continue;
}
if (query.get("monitor") && query.get("monitor") !== monitors[i].tag) {
continue;
}
delete monitors[i].api;
delete monitors[i].default_status;
let data = await FetchData(siteData, monitors[i], parentData.localTz, parentData.selectedLang, parentData.lang);
monitors[i].pageData = data;
monitors[i].activeIncidents = [];
monitorsActive.push(monitors[i]);
}
let startWithin = moment().subtract(siteData.homeIncidentStartTimeWithin, "days").unix();
let endWithin = moment().add(siteData.homeIncidentStartTimeWithin, "days").unix();
let allOpenIncidents = await GetIncidentsOpenHome(siteData.homeIncidentCount, startWithin, endWithin);
//if not home page
let isCategoryPage = !!query.get("category") && query.get("category") !== "Home";
let isMonitorPage = !!query.get("monitor");
if (isMonitorPage && monitorsActive.length > 0) {
let isCategoryPage = pageType === "category";
let isMonitorPage = pageType === "monitor";
let isGroupPage = pageType === "group";
if (isMonitorPage) {
pageTitle = monitorsActive[0].name + " - " + pageTitle;
pageDescription = monitorsActive[0].description;
canonical = canonical + "?monitor=" + monitorsActive[0].tag;
hero = {
title: monitorsActive[0].name,
subtitle: monitorsActive[0].description,
image: monitorsActive[0].image,
};
}
if (isGroupPage) {
pageTitle = group.name + " - " + pageTitle;
pageDescription = group.description;
canonical = canonical + "?group=" + group.tag;
hero = {
title: group.name,
subtitle: group.description,
image: group.image,
};
}
//if category page
if (isCategoryPage) {
let allCategories = siteData.categories;
let selectedCategory = allCategories.find((category) => category.name === requiredCategory);
let selectedCategory = allCategories.find((category) => category.name === query.get("category"));
if (selectedCategory) {
pageTitle = selectedCategory.name + " - " + pageTitle;
pageDescription = selectedCategory.description;
canonical = canonical + "?category=" + requiredCategory;
canonical = canonical + "?category=" + query.get("category");
hero = {
title: selectedCategory.name,
subtitle: selectedCategory.description,
image: selectedCategory.image,
};
}
}
if (isCategoryPage || isMonitorPage) {
if (isCategoryPage || isMonitorPage || isGroupPage) {
let eligibleTags = monitorsActive.map((monitor) => monitor.tag);
//filter incidents that have monitor_tag in monitors
allOpenIncidents = allOpenIncidents.filter((incident) => {
@@ -105,11 +183,11 @@ export async function load({ parent, url }) {
monitors: monitorsActive,
allRecentIncidents,
allRecentMaintenances,
categoryName: requiredCategory,
isCategoryPage: isCategoryPage,
isMonitorPage: isMonitorPage,
pageType,
pageTitle: pageTitle,
pageDescription: removeTags(pageDescription),
hero,
categoryName: query.get("category"),
canonical,
};
}

View File

@@ -32,23 +32,6 @@
}
}
let isHome = !data.isCategoryPage && !data.isMonitorPage;
if (data.isCategoryPage) {
let category = data.site.categories.find((e) => e.name == data.categoryName);
if (!!category) {
data.site.hero.title = category.name;
data.site.hero.subtitle = category.description;
}
} else if (data.isMonitorPage) {
let monitor = data.monitors[0];
if (!!monitor) {
data.site.hero.title = monitor.name;
data.site.hero.subtitle = monitor.description;
data.site.hero.image = monitor.image;
}
}
onMount(() => {
pageLoaded = true;
});
@@ -79,39 +62,40 @@
{/if}
</svelte:head>
<div class="mt-12"></div>
{#if data.site.hero}
{#if data.hero}
<section class="mx-auto mb-8 flex w-full max-w-[655px] flex-1 flex-col items-start justify-center">
<div class="mx-auto max-w-screen-xl px-4 lg:flex lg:items-center">
<div class="blurry-bg mx-auto max-w-3xl text-center">
{#if data.site.hero.image}
{#if data.hero.image}
<GMI src={data.site.hero.image} classList="m-auto mb-2 h-14 w-14" alt="" srcset="" />
{/if}
{#if data.site.hero.title}
{#if data.hero.title}
<h1
class="bg-gradient-to-r from-green-300 via-blue-500 to-purple-600 bg-clip-text text-5xl font-extrabold leading-tight text-transparent"
>
{@html data.site.hero.title}
{@html data.hero.title}
</h1>
{/if}
{#if data.site.hero.subtitle}
{#if data.hero.subtitle}
<h2 class="mx-auto mt-4 max-w-xl sm:text-xl">
{@html data.site.hero.subtitle}
{@html data.hero.subtitle}
</h2>
{/if}
</div>
</div>
</section>
{/if}
{#if !isHome}
{#if data.pageType != "home"}
<section class="mx-auto my-2 flex w-full max-w-[655px] flex-1 flex-col items-start justify-center">
<Button
variant="outline"
class="bounce-left h-8 justify-start pl-1.5"
on:click={() => {
if (data.isCategoryPage) {
if (data.pageType == "category") {
return window.history.back();
}
if (data.isMonitorPage) {
} else if (data.pageType == "monitor") {
return (window.location.href = `${base}/`);
} else if (data.pageType == "group") {
return (window.location.href = `${base}/`);
}
}}
@@ -222,7 +206,7 @@
</Card.Root>
</section>
{/if}
{#if data.site.categories && isHome}
{#if data.site.categories && data.pageType == "home"}
<section
class="relative z-10 mx-auto mb-8 w-full max-w-[890px] flex-1 flex-col items-start backdrop-blur-[2px] md:w-[655px]"
>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 670 KiB

View File

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

View File

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

View File

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 161 KiB

View File

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

View File

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

View File

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 193 KiB

View File

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View File

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

View File

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View File

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

View File

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

View File

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 167 KiB

View File

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 174 KiB

View File

Before

Width:  |  Height:  |  Size: 168 B

After

Width:  |  Height:  |  Size: 168 B

View File

Before

Width:  |  Height:  |  Size: 149 B

After

Width:  |  Height:  |  Size: 149 B

View File

Before

Width:  |  Height:  |  Size: 300 B

After

Width:  |  Height:  |  Size: 300 B

View File

Before

Width:  |  Height:  |  Size: 149 B

After

Width:  |  Height:  |  Size: 149 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 655 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 677 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB