diff --git a/.gitignore b/.gitignore index caa9c131..21bb28d6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ static/kener build config/monitors.yaml config/site.yaml +config/server.yaml /.svelte-kit /src/lib/.kener /package @@ -19,6 +20,4 @@ config/static/* db/* !db/.kener database/* -!database/.kener -src/lib/server/config/monitors.yaml -src/lib/server/config/site.yaml \ No newline at end of file +!database/.kener \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 0c1b88f4..f8deb0d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,19 @@ FROM lsiobase/alpine:3.18 as base -ENV TZ=Etc/GMT +ENV TZ=Etc/UTC RUN \ echo "**** install build packages ****" && \ apk add --no-cache \ nodejs \ - npm && \ + npm \ + python3 \ + make \ + gcc \ + g++ \ + sqlite \ + sqlite-dev \ + libc6-compat && \ echo "**** cleanup ****" && \ rm -rf \ /root/.cache \ @@ -15,12 +22,12 @@ RUN \ # set OS timezone specified by docker ENV RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - +# ARG data_dir=/config +# VOLUME $data_dir +# ENV CONFIG_DIR=$data_dir COPY docker/root/ / - - # build requires devDependencies which are not used by production deploy # so build in a stage so we can copy results to clean "deploy" stage later FROM base as build @@ -29,7 +36,10 @@ WORKDIR /app COPY --chown=abc:abc . /app -RUN npm install \ +RUN \ + npm install node-gyp -g && \ + npm_config_build_from_source=true npm install better-sqlite3 && \ + npm install \ && chown -R root:root node_modules \ && npm run build @@ -47,15 +57,13 @@ COPY --chown=abc:abc build.js /app/build.js COPY --chown=abc:abc sitemap.js /app/sitemap.js COPY --chown=abc:abc openapi.json /app/openapi.json COPY --chown=abc:abc src/lib/server /app/src/lib/server -COPY --chown=abc:abc src/lib/helpers.js /app/src/lib/helpers.js COPY --from=build --chown=abc:abc /app/build /app/build COPY --from=build --chown=abc:abc /app/main.js /app/main.js - ENV NODE_ENV=production -# install prod depdendencies and clean cache +# install prod dependencies and clean cache RUN npm install --omit=dev \ && npm cache clean --force \ && chown -R abc:abc node_modules @@ -67,4 +75,4 @@ EXPOSE $PORT # leave entrypoint blank! # uses LSIO s6-init entrypoint with scripts # that populate CONFIG_DIR with static dir, monitor/site.yaml when dir is empty -# and chown's all files so they are owned by proper user based on PUID/GUID env +# and chown's all files so they are owned by proper user based on PUID/GUID env \ No newline at end of file diff --git a/README.md b/README.md index a8529701..b439f423 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,6 @@

- #### [πŸ‘‰ Visit a live server](https://kener.ing) #### [πŸ‘‰ Quick Start](https://kener.ing/docs/quick-start) @@ -35,9 +34,9 @@ ## What is Kener? -Kener: Open-source sveltekit status page tool, designed to make service monitoring and incident handling a breeze. It offers a sleek and user-friendly interface that simplifies tracking service outages and improves how we communicate during incidents. Kener integrates seamlessly with GitHub, making incident management a team effortβ€”making. +Kener: Open-source sveltekit status page system, crafted with lot of thought so that it looks modern. -It uses files to store the data. +It does not aim to replace the Datadogs of the world. It simply tries to help someone come with a status page for the world. Kener name is derived from the word "Kene" which means "how is it going" in Assamese, then .ing is added to make cooler.
@@ -60,6 +59,7 @@ Here are some of the features that you get out of the box. Please read the docum - Supports a Default Status for Monitors. Example defaultStatus=DOWN if you don't hit API per minute with Status UP - Supports base path for hosting in k8s - Pre-built docker image for easy deployment +- Supports webhooks/discord/slack for notifications ### Customization and Branding diff --git a/build.js b/build.js index 45d38ecf..323570b0 100644 --- a/build.js +++ b/build.js @@ -4,11 +4,14 @@ import axios from "axios"; import { IsValidURL, checkIfDuplicateExists, - getWordsStartingWithDollar, IsValidHTTPMethod, - ValidateIpAddress + ValidateIpAddress, + IsValidHost, + IsValidRecordType, + IsValidNameServer } from "./src/lib/server/tool.js"; import { API_TIMEOUT, AnalyticsProviders } from "./src/lib/server/constants.js"; +import { GetAllGHLabels, CreateGHLabel } from "./src/lib/server/github.js"; const configPathFolder = "./config"; const databaseFolder = process.argv[2] || "./database"; @@ -25,9 +28,55 @@ const defaultEval = `(function (statusCode, responseTime, responseData) { latency: responseTime, } })`; + +function validateServerFile(server) { + //if empty return true + if (Object.keys(server).length === 0) { + return true; + } + //server.triggers is present then it should be an array + if (server.triggers !== undefined && !Array.isArray(server.triggers)) { + console.log("triggers should be an array"); + return false; + } + ///each trigger should have a name, type, and url + if (server.triggers !== undefined) { + for (let i = 0; i < server.triggers.length; i++) { + const trigger = server.triggers[i]; + if ( + trigger.name === undefined || + trigger.type === undefined || + trigger.url === undefined + ) { + console.log("trigger should have name, type, and url"); + return false; + } + } + } + //if database is present then it should be an object, and they key can be either postgres or sqlite + if (server.database !== undefined && typeof server.database !== "object") { + console.log("database should be an object"); + return false; + } + if (server.database !== undefined) { + let dbtype = Object.keys(server.database); + if (dbtype.length !== 1) { + console.log("database should have only one key"); + return false; + } + if (dbtype[0] !== "postgres" && dbtype[0] !== "sqlite") { + console.log("database should be either postgres or sqlite"); + return false; + } + } + + return true; +} + async function Build() { - console.log("Building Kener..."); + console.log("ℹ️ Building Kener..."); let site = {}; + let server = {}; let monitors = []; try { site = yaml.load(fs.readFileSync(configPathFolder + "/site.yaml", "utf8")); @@ -36,6 +85,15 @@ async function Build() { console.log(error); process.exit(1); } + try { + server = yaml.load(fs.readFileSync(configPathFolder + "/server.yaml", "utf8")); + if (!validateServerFile(server)) { + process.exit(1); + } + } catch (error) { + console.warn("server.yaml not found"); + server = {}; + } if ( site.github === undefined || @@ -43,12 +101,23 @@ async function Build() { site.github.repo === undefined ) { console.log("github owner and repo are required"); - process.exit(1); + site.hasGithub = false; + // process.exit(1); + } else { + site.hasGithub = true; + } + + if (site.hasGithub && !!!site.github.incidentSince) { + site.github.incidentSince = 720; + } + if (site.hasGithub && !!!site.github.apiURL) { + site.github.apiURL = "https://api.github.com"; } const FOLDER_DB = databaseFolder; const FOLDER_SITE = FOLDER_DB + "/site.json"; const FOLDER_MONITOR = FOLDER_DB + "/monitors.json"; + const FOLDER_SERVER = FOLDER_DB + "/server.json"; for (let i = 0; i < monitors.length; i++) { const monitor = monitors[i]; @@ -57,6 +126,7 @@ async function Build() { let tag = monitor.tag; let hasAPI = monitor.api !== undefined && monitor.api !== null; let hasPing = monitor.ping !== undefined && monitor.ping !== null; + let hasDNS = monitor.dns !== undefined && monitor.dns !== null; let folderName = name.replace(/[^a-z0-9]/gi, "-").toLowerCase(); monitors[i].folderName = folderName; @@ -121,6 +191,44 @@ async function Build() { } monitors[i].hasPing = true; } + if (hasDNS) { + let dnsData = monitor.dns; + let domain = dnsData.host; + //check if domain is valid + if (!!!domain || !IsValidHost(domain)) { + console.log("domain is not valid"); + process.exit(1); + } + + let recordType = dnsData.lookupRecord; + //check if recordType is valid + if (!!!recordType || !IsValidRecordType(recordType)) { + console.log("recordType is not valid"); + process.exit(1); + } + + let nameServer = dnsData.nameServer; + //check if nameserver is valid + if (!!nameServer && !IsValidNameServer(nameServer)) { + console.log("nameServer is not valid"); + process.exit(1); + } + + // matchType: "ANY" # ANY, ALL + let matchType = dnsData.matchType; + if (!!!matchType || (matchType !== "ANY" && matchType !== "ALL")) { + console.log("matchType is not valid"); + process.exit(1); + } + + //values array of string at least one + let values = dnsData.values; + if (!!!values || !Array.isArray(values) || values.length === 0) { + console.log("values is not valid"); + process.exit(1); + } + monitors[i].hasDNS = true; + } if (hasAPI) { let url = monitor.api.url; let method = monitor.api.method; @@ -222,35 +330,14 @@ async function Build() { } } } catch (error) { - console.log(error); + console.log(`error while fetching ${url}`); } } } - monitors[i].path0Day = `${FOLDER_DB}/${folderName}.0day.utc.json`; - monitors[i].path90Day = `${FOLDER_DB}/${folderName}.90day.utc.json`; monitors[i].hasAPI = hasAPI; - - //secrets can be in url/body/headers - //match in monitor.url if a words starts with $, get the word - const requiredSecrets = getWordsStartingWithDollar( - `${monitor.url} ${monitor.body} ${JSON.stringify(monitor.headers)}` - ).map((x) => x.substr(1)); - - //iterate over process.env - for (const [key, value] of Object.entries(process.env)) { - if (requiredSecrets.indexOf(key) !== -1) { - envSecrets.push({ - find: `$${key}`, - replace: value - }); - } - } } - if (site.github.incidentSince === undefined || site.github.incidentSince === null) { - site.github.incidentSince = 720; - } if (site.siteName === undefined) { site.siteName = site.title; } @@ -260,6 +347,22 @@ async function Build() { if (site.themeToggle === undefined) { site.themeToggle = true; } + if (site.barStyle === undefined) { + site.barStyle = "FULL"; + } + if (site.barRoundness === undefined) { + site.barRoundness = "ROUNDED"; + } else { + site.barRoundness = site.barRoundness.toLowerCase(); + } + if (site.summaryStyle === undefined) { + site.summaryStyle = "DAY"; + } + site.colors = { + UP: site.colors?.UP || "#4ead94", + DOWN: site.colors?.DOWN || "#ca3038", + DEGRADED: site.colors?.DEGRADED || "#e6ca61" + }; if (!!site.analytics) { const providers = {}; @@ -293,14 +396,65 @@ async function Build() { fs.ensureFileSync(FOLDER_MONITOR); fs.ensureFileSync(FOLDER_SITE); - + fs.ensureFileSync(FOLDER_SERVER); try { fs.writeFileSync(FOLDER_MONITOR, JSON.stringify(monitors, null, 4)); fs.writeFileSync(FOLDER_SITE, JSON.stringify(site, null, 4)); + fs.writeFileSync(FOLDER_SERVER, JSON.stringify(server, null, 4)); } catch (error) { console.log(error); process.exit(1); } + + console.log("βœ… Kener built successfully"); + + if (site.hasGithub) { + const ghLabels = await GetAllGHLabels(site); + const tagsAndDescription = monitors.map((monitor) => { + return { tag: monitor.tag, description: monitor.name }; + }); + //add incident label if does not exist + + if (ghLabels.indexOf("incident") === -1) { + await CreateGHLabel(site, "incident", "Status of the site"); + } + if (ghLabels.indexOf("resolved") === -1) { + await CreateGHLabel(site, "resolved", "Incident is resolved", "65dba6"); + } + if (ghLabels.indexOf("identified") === -1) { + await CreateGHLabel(site, "identified", "Incident is Identified", "EBE3D5"); + } + if (ghLabels.indexOf("manual") === -1) { + await CreateGHLabel(site, "manual", "Manually Created Incident", "6499E9"); + } + if (ghLabels.indexOf("auto") === -1) { + await CreateGHLabel(site, "auto", "Automatically Created Incident", "D6C0B3"); + } + if (ghLabels.indexOf("investigating") === -1) { + await CreateGHLabel(site, "investigating", "Incident is investigated", "D4E2D4"); + } + if (ghLabels.indexOf("incident-degraded") === -1) { + await CreateGHLabel( + site, + "incident-degraded", + "Status is degraded of the site", + "f5ba60" + ); + } + if (ghLabels.indexOf("incident-down") === -1) { + await CreateGHLabel(site, "incident-down", "Status is down of the site", "ea3462"); + } + //add tags if does not exist + for (let i = 0; i < tagsAndDescription.length; i++) { + const tag = tagsAndDescription[i].tag; + const description = tagsAndDescription[i].description; + if (ghLabels.indexOf(tag) === -1) { + await CreateGHLabel(site, tag, description); + } + } + + console.log("βœ… Github labels created successfully"); + } } Build(); diff --git a/config/monitors.example.yaml b/config/monitors.example.yaml index f6819498..ed5b0880 100644 --- a/config/monitors.example.yaml +++ b/config/monitors.example.yaml @@ -17,3 +17,32 @@ api: method: GET url: https://www.frogment.com + alerts: + DOWN: + failureThreshold: 5 + successThreshold: 2 + createIncident: false + description: "Write a description here please" + triggers: + - MyWebhook + - Discord Test +- name: CNAME Lookup + description: Monitor example showing how to lookup CNAME record for a domain. The site www.rajnandan.com is hosted on GitHub Pages. + tag: "cname-rajnandan" + image: "https://www.rajnandan.com/assets/images/me.jpg" + defaultStatus: "UP" + dns: + host: "www.rajnandan.com" + lookupRecord: "CNAME" + nameServer: "8.8.8.8" + matchType: "ANY" # ANY, ALL + values: + - "rajnandan1.github.io" +- name: "Frogment APP Ping" + description: "Ping www.frogment.app" + image: https://www.frogment.app/icons/Square107x107Logo.png + tag: "pingFrogmentApp" + defaultStatus: "UP" + ping: + hostsV4: + - "www.frogment.app" \ No newline at end of file diff --git a/config/server.example.yaml b/config/server.example.yaml new file mode 100644 index 00000000..4ed8f8cf --- /dev/null +++ b/config/server.example.yaml @@ -0,0 +1,13 @@ +triggers: + - name: MyWebhook + type: webhook + url: https://kener.requestcatcher.com/test + method: POST + headers: + Authorization: Bearer SomeToken + - name: Discord Test + type: discord + url: https://discord.com/api/webhooks/1310641119767302164/XJvq4MO2lz5yp9XRCJgfc4dbUfcQdHsttFUKTFJx4y_Oo1jNkUXf-CS3RSnamnNNv4Lx +database: + sqlite: + dbName: kener.db \ No newline at end of file diff --git a/config/site.example.yaml b/config/site.example.yaml index b5d1f16c..5b57d1db 100644 --- a/config/site.example.yaml +++ b/config/site.example.yaml @@ -52,4 +52,7 @@ pattern: "squares" analytics: - id: "G-Q3MLRXCBFT" type: "GA" +barRoundness: SHARP +summaryStyle: CURRENT +barStyle: PARTIAL diff --git a/delay.js b/delay.js new file mode 100644 index 00000000..1c0ce17e --- /dev/null +++ b/delay.js @@ -0,0 +1,40 @@ +import fs from "fs-extra"; +import path from "path"; + +let maxWait = 5000; +let interval = 1000; +let waitTime = 0; +let serverDataPath = path.join(process.cwd(), "database", "server.json"); +let siteDataPath = path.join(process.cwd(), "database", "site.json"); +let monitorsDataPath = path.join(process.cwd(), "database", "monitors.json"); + +function allFilesExist() { + return ( + fs.existsSync(serverDataPath) && + fs.existsSync(siteDataPath) && + fs.existsSync(monitorsDataPath) + ); +} + +//use setTimeout to create a delay promise +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +let requiredFilesExist = allFilesExist(); + +//create anonymous function to call the init function +(async function init() { + while (!requiredFilesExist && waitTime < maxWait) { + await delay(1000); + requiredFilesExist = allFilesExist(); + + waitTime += interval; + } + if (!requiredFilesExist) { + console.error("Error loading site data"); + process.exit(1); + } else { + console.log("βœ… All files exist. Starting Frontend server..."); + } +})(); diff --git a/docker-compose.yml b/docker-compose.yml index d33da656..c5c9ef62 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: container_name: kener-rc #env_file: .env #uncomment this, if you are using .env file environment: - - TZ=Etc/GMT + - TZ=Etc/UTC #- GH_TOKEN= #- API_TOKEN= #- API_IP= diff --git a/docker/root/etc/s6-overlay/s6-rc.d/init-app-config/run b/docker/root/etc/s6-overlay/s6-rc.d/init-app-config/run index a6791d9e..1f4a731b 100755 --- a/docker/root/etc/s6-overlay/s6-rc.d/init-app-config/run +++ b/docker/root/etc/s6-overlay/s6-rc.d/init-app-config/run @@ -25,8 +25,8 @@ if [ "$POPULATE_EXAMPLES" = true ]; then echo "Directory is empty, adding defaults..." mkdir -p "${CONFIG_DIR}"/static cp -r /app/static/. "${CONFIG_DIR}"/static - mv /app/config/monitors.example.yaml "${CONFIG_DIR}"/monitors.yaml - mv /app/config/site.example.yaml "${CONFIG_DIR}"/site.yaml + cp /app/config/monitors.example.yaml "${CONFIG_DIR}"/monitors.yaml + cp /app/config/site.example.yaml "${CONFIG_DIR}"/site.yaml fi # permissions diff --git a/docs/alerting.md b/docs/alerting.md new file mode 100644 index 00000000..190ac254 --- /dev/null +++ b/docs/alerting.md @@ -0,0 +1,160 @@ +--- +title: Alert Config | Server.yaml | Kener +description: Alerts are the heart of Kener. This is where you define the alerts you want to show on your site. +--- + +# Add Alert Config + +Use the `config/server.yaml` file to configure the alert settings. + +## Alert Triggers + +Kener supports multiple alerting mechanisms. + +Currently, Kener supports the following alerting mechanisms: + +- Webhook +- Discord +- Slack + +We are adding more alerting mechanisms in the future. + +### Configure Alerts + +In the `server.yaml` file, you can add the alerting mechanisms under the `triggers` key. It accepts an array of objects. + +```yaml +triggers: + - name: Awesome Webhook + type: "webhook" + url: "https://kener.requestcatcher.com/test" + method: "POST" + headers: + Authorization: Bearer $SOME_TOKEN_FROM_ENV + - name: My Discord Channel + type: "discord" + url: "https://discord.com/api/webhooks/your-webhook-url" + - name: Some Slack Channel + type: slack + url: https://hooks.slack.com/services/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 +``` + +| Key | Description | +| ---- | ----------------------------------------------------------------------- | +| name | Name of the alerting mechanism. This will be used in the monitor config | +| type | Type of the alerting mechanism. Can be `webhook`, `discord`, `slack` | +| url | URL of the webhook or discord or slack channel | + +There may be additional keys based on the type of alerting mechanism. + +In webhook alerting, you can also add headers and method to the request. + +```yaml +- name: Awesome Webhook + type: "webhook" + url: "https://kener.requestcatcher.com/test" + method: "POST" + headers: + Authorization: Bearer $SOME_TOKEN_FROM_ENV +``` + +### Webhook + +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" + } + ] +} +``` + +| Key | Description | +| --------------------- | ----------------------------------------------------------- | +| id | Unique ID of the alert | +| alert_name | Name of the alert | +| severity | Severity of the alert. Can be `critical`, `warn` | +| status | Status of the alert. Can be `TRIGGERED`, `RESOLVED` | +| source | Source of the alert. Can be `Kener` | +| timestamp | Timestamp of the alert | +| description | Description of the alert. This you can customize. See below | +| details | Details of the alert. | +| details.metric | Name of the monitor | +| details.current_value | Current value of the monitor | +| details.threshold | Alert trigger hreshold of the monitor | +| actions | Actions to be taken. Link to view the monitor. | + +### Discord + +The discord message when alert is `TRIGGERED` will look like this + +![Discord](/discord.png) + +The discord message when alert is `RESOLVED` will look like this + +![Discord](/discord_resolved.png) + +### Slack + +The slack message when alert is `TRIGGERED` will look like this + +![Slack](/slack.png) + +The slack message when alert is `RESOLVED` will look like this + +![Slack](/slack_resolved.png) + +### Add Alerts to Monitors + +Once you have set up the triggers, you can add them to your monitors in the `config/monitors.yaml` file. + +```yaml +- name: OkBookmarks + tag: "okbookmarks" + image: "https://okbookmarks.com/assets/img/extension_icon128.png" + api: + method: GET + url: https://okbookmarks.com + alerts: + DOWN: + failureThreshold: 1 + successThreshold: 1 + createIncident: true + description: "🚨 **Service Alert**. This is a custom message" + triggers: + - Awesome Webhook + - My Discord Channel + - Some Slack Channel +``` + +The `alerting` object lets you define the alerting mechanism for the monitor. It can do alerting for `DOWN` or `DEGRADED` status. You can add both or one of them. + +- `failureThreshold`: Number of consecutive failures before alerting +- `successThreshold`: Number of consecutive successes before resolving the alert +- `createIncident`: If set to `true`, Kener will create an incident that will be shown on the status page +- `description`: Custom message for the alert +- `triggers`: Array of alerting triggers to send the alert to. The name should match the name in the `server.yaml` file. + +
+

+ It will send alerts to the webhook, discord, and slack channels. + The alert will be set when the monitor goes down for 1 health check. + There will be one more alert when the monitor is up again after, 1 health check is successful. +

+
diff --git a/docs/changelogs.md b/docs/changelogs.md index 75543bda..ba3e7706 100644 --- a/docs/changelogs.md +++ b/docs/changelogs.md @@ -5,6 +5,30 @@ description: Changelogs for Kener # Changelogs +## v2.0.0 + + + + πŸš€ + + +Here are the changes in this release + +### Features + +- Added support for sqlite3 and removed dependency on file system +- Added support for postgres database. Read more [here](/docs/database) +- Added support for alerting. Read more [here](/docs/alerting) +- Added color customization. Read more [here](/docs/customize-site#color) +- Added three new customizations for home page. Read more [here](/docs/customize-site#barstyle) + - `barStyle` + - `barRoundness` + - `summaryStyle` + +### Migration + +Kener will automatically migrate your data from file system to sqlite3. If you are using a custom domain, you need to update the `site.yaml` file with the new `siteURL` field. Read more [here](/docs/customize-site#siteURL) + ## v0.0.16 diff --git a/docs/customize-site.md b/docs/customize-site.md index 4f88871c..d8741c6c 100644 --- a/docs/customize-site.md +++ b/docs/customize-site.md @@ -91,7 +91,12 @@ This is the location where someone will be taken when they click on the site nam ## theme -This is the default theme of the site that will be used when a user lands for the first time. It can be `light` or `dark` or `system`. Defaults to `system`. The user still gets the option to change the theme. +This is the default theme of the site that will be used when a user lands for the first time. It can be `light` or `dark` or `system` or `none`. Defaults to `system`. The user still gets the option to change the theme. + +- setting it to `light` will always set the theme to light when page loads +- setting it to `dark` will always set the theme to dark when page loads +- setting it to `system` will set the theme based on the system preference +- setting it to `none` will set the theme to last selected theme by the user ## themeToggle @@ -114,15 +119,20 @@ Example add a png called `logo.png` file in `static/` and then ## github -For incident kener uses github comments. Create an empty [github](https://github.com) repo and add them to `site.yaml` +For incident kener uses github issues. Create an empty [github](https://github.com) repo and add them to `site.yaml` ```yaml github: + apiURL: "https://api.github.com" owner: "username" repo: "repository" - incidentSince: 72 + incidentSince: 720 ``` +### apiURL + +API URL of the github. Default is `https://api.github.com`. If you are using github enterprise then you can change it to your github enterprise api url. + ### owner Owner of the github repository. If the repository is `https://github.com/rajnandan1/kener` then the owner is `rajnandan1` @@ -133,7 +143,11 @@ Repository name of the github repository. If the repository is `https://github.c ### incidentSince -`incidentSince` is in hours. It means if an issue is created before X hours then kener would not honor it. What it means is that kener would not show it active incident pages nor it will update the uptime. Default is 30\*24 hours. +`incidentSince` is in hours. It means if an issue is created before X hours then kener would not honor it. What it means is that kener would not show it active incident pages nor it will update the uptime. Default is 30\*24 hours = 720 hours. + +To read how to set up github for kener [click here](/docs/gh-setup) + +To see how to create an issue [click here](/docs/incident-management) --- @@ -332,3 +346,46 @@ analytics: - id: "FKOdsKener" type: "MIXPANEL" ``` + +## barStyle + +Kener shows a bar for a day. By default if any downtime or degradation is there the bar will be red or yellow respectively. + +If you want to only show a part of the bar as red or yellow then you can set the `barStyle` to `PARTIAL`. Default is `FULL` + +```yaml +barStyle: PARTIAL +``` + +--- + +## barRoundness + +You can set the roundness of the bar. It can be `SHARP` or `ROUNDED`. Default is `ROUNDED` + +```yaml +barRoundness: ROUNDED +``` + +--- + +## summaryStyle + +The summary of the monitor today is shown in the monitor page. By default it shows the the whole day summary. If you want to show the current status then you can set the `summaryStyle` to `CURRENT`. Default is `DAY`. + +```yaml +summaryStyle: CURRENT +``` + +--- + +## colors + +You can set the colors of UP, DOWN, DEGRADED + +```yaml +colors: + UP: "#4ead94" + DOWN: "#ca3038" + DEGRADED: "#e6ca61" +``` diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 00000000..45cc18a9 --- /dev/null +++ b/docs/database.md @@ -0,0 +1,59 @@ +--- +title: Database Config - Server.yaml - Kener +description: Add database configuration to your kener server.yaml +--- + +# Database Config + +Use the `config/server.yaml` file to configure the database settings. + +## Supported Database + +- Sqlite (default) +- Postgres + +We are adding more database support in the future. + +## Sqlite + +Sqlite is the default database for Kener. You don't need to do anything to use it. The database file will be created in the `database` folder. + +The name of the default database file is `kener.db`. The path will be `database/kener.db`. + +You can change the database file name by changing the `database` key in the `server.yaml` file. + +```yaml +database: + sqlite: + dbName: awesomeKener.db +``` + +In this case, the database file will be created in the `database` folder with the name `awesomeKener.db`. + +Make sure the `database` folder is writable by the Kener process. + +## Postgres + +To use Postgres, you need to provide the connection details in the `server.yaml` file. + +```yaml +database: + postgres: + host: localhost + port: 5432 + user: kener + password: kener + database: kener +``` + +Or if you want to use environment variables, you can do that as well. Make sure the environment variables are set before starting the Kener process. The environment variables should be `PG_HOST`, `PG_PORT`, `PG_USER`, `PG_PASSWORD`, and `PG_DB`. + +```yaml +database: + postgres: + host: $PG_HOST + port: $PG_PORT + user: $PG_USER + password: $PG_PASSWORD + database: $PG_DB +``` diff --git a/docs/deployment.md b/docs/deployment.md index 16b916c9..a0eb606b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -30,7 +30,7 @@ npm run prod ```shell npm i -node build.js +npm run build pm2 start src/lib/server/startup.js pm2 start main.js ``` @@ -49,7 +49,7 @@ docker.io/rajnandan1/kener:latest ghcr.io/rajnandan1/kener:latest ``` -You should mount two host directories to persist your configuration and database. [Environmental variables](https://rajnandan1.github.io/kener-docs/docs/environment-vars) can be passed with `-e` An example `docker run` command: +You should mount two host directories to persist your configuration and database. [Environmental variables](/docs/environment-vars) can be passed with `-e` An example `docker run` command: Make sure `./database` and `./config` directories are present in the root directory diff --git a/docs/home.md b/docs/home.md index f28523f4..4aebda31 100644 --- a/docs/home.md +++ b/docs/home.md @@ -35,7 +35,7 @@ description: Kener is an open-source Node.js status page tool, designed to make ⁉
-
+
@@ -455,117 +201,159 @@ ? 'col-span-12' : 'col-span-8'} md:col-span-8" > - - + + {uptimesRollers[rolledAt].text} + + + + {#if rollerLoading} + + {:else} + + {/if} + + +
- {#if _90Day[todayDD]} -
- {summaryTime(lang, _90Day[todayDD].message)} -
- {/if} -
- - {#if view == "90day"} -
-
- {#each Object.entries(_90Day) as [ts, bar]} -
{ - show90Inline(e, bar); - }} - class="oneline h-[30px] w-[6px] rounded-sm" - > -
-
- {#if bar.showDetails} -
-
- {n( - lang, - new Date(bar.timestamp * 1000).toLocaleDateString() - )} - {summaryTime(lang, bar.message)} -
-
- {/if} - {/each} +
+ {summaryTime(lang, monitor.pageData.summaryText)}
- {:else} -
-
- {#if Object.keys(_0Day).length == 0} - - {/if} - {#each Object.entries(_0Day) as [ts, bar]} +
+
+
+ {#each Object.entries(_90Day) as [ts, bar]} + { + show90Inline(e, bar); + }} + on:click={(e) => { + dailyDataGetter(e, bar); + }} + style="transition: border-color {bar.ij * 2 + 100}ms ease-in;" + href="#" + class="oneline h-[34px] w-[6px] border-b-2 {bar.border + ? 'border-indigo-400' + : 'border-transparent'} pb-1" + >
-
-
-

- ● - {ampm( - lang, - n( - lang, - new Date(bar.timestamp * 1000).toLocaleTimeString() - ) - )} -

- {#if bar.status != "NO_DATA"} -

- {l(lang, "statuses." + bar.status)} -

- {:else} -

-

- {/if} +
+ {#if bar.showDetails} +
+
+ {moment(new Date(bar.timestamp * 1000)).format( + "dddd, MMMM Do, YYYY" + )} - + {summaryTime(lang, bar.message)}
- {/each} -
+ {/if} + {/each}
- {/if} + {#if showDailyDataModal} +
{ + let classList = JSON.stringify(e.explicitOriginalTarget.classList); + + if (classList.indexOf("oneline") != -1) { + return; + } + showDailyDataModal = false; + }} + class="absolute -left-2 top-10 z-10 mx-auto rounded-sm border bg-card px-[7px] py-[7px] shadow-lg md:w-[560px]" + > +
+ {dateFetchedFor} + {#if !loadingDayData} + + + {dayUptime}% + {/if} +
+
+ {#if loadingDayData} + + {:else} + {#each Object.entries(_0Day) as [ts, bar]} +
+
+
+

+ ● + {ampm( + lang, + n( + lang, + new Date( + bar.timestamp * 1000 + ).toLocaleTimeString() + ) + )} +

+ {#if bar.status != "NO_DATA"} +

+ {l(lang, "statuses." + bar.status)} +

+ {:else} +

-

+ {/if} +
+
+ {/each} + {/if} +
+
+ {/if} +
+ {#if !!!monitor.embed} -

+

{l(lang, "root.recent_incidents")} @@ -574,53 +362,3 @@

- - diff --git a/src/lib/components/shareMenu.svelte b/src/lib/components/shareMenu.svelte new file mode 100644 index 00000000..23161dae --- /dev/null +++ b/src/lib/components/shareMenu.svelte @@ -0,0 +1,295 @@ + + +
+ {#if !!monitor.image} + {monitor.name} + {/if} +

+ {monitor.name} +

+
+
+

+ {l(lang, "monitor.share")} +

+

+ {l(lang, "monitor.share_desc")} +

+ +
+
+
+

+ {l(lang, "monitor.embed")} +

+

+ {l(lang, "monitor.embed_desc")} +

+
+
+

+ {l(lang, "monitor.theme")} +

+ +
+ + +
+
+ + +
+ +
+
+
+

+ {l(lang, "monitor.mode")} +

+ +
+ + +
+
+ + +
+ +
+
+
+ +
+
+
+

+ {l(lang, "monitor.badge")} +

+

+ {l(lang, "monitor.badge_desc")} +

+ + +
+
+
+

+ {l(lang, "monitor.status_svg")} +

+

+ {l(lang, "monitor.status_svg_desc")} +

+ + +
diff --git a/src/lib/helpers.js b/src/lib/helpers.js deleted file mode 100644 index 94973e91..00000000 --- a/src/lib/helpers.js +++ /dev/null @@ -1,37 +0,0 @@ -// @ts-nocheck -const StatusObj = { - UP: "api-up", - DEGRADED: "api-degraded", - DOWN: "api-down", - NO_DATA: "api-nodata" -}; -const StatusColor = { - UP: "00dfa2", - DEGRADED: "ffb84c", - DOWN: "ff0060", - NO_DATA: "b8bcbe" -}; -// @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)); -}; -const ParsePercentage = function (n) { - if (isNaN(n)) return "-"; - if (n == 0) { - return "0"; - } - if (n == 100) { - return "100"; - } - return n.toFixed(4); -}; -export { StatusObj, StatusColor, ParseUptime, ParsePercentage }; diff --git a/src/lib/server/alerting.js b/src/lib/server/alerting.js new file mode 100644 index 00000000..85c1b378 --- /dev/null +++ b/src/lib/server/alerting.js @@ -0,0 +1,217 @@ +// @ts-nocheck +import notification from "./notification/notif.js"; +import { ParseIncidentPayload, GHIssueToKenerIncident } from "./webhook.js"; +import { + CreateIssue, + GetIncidentByNumber, + UpdateIssueLabels, + AddComment, + CloseIssue +} from "./github.js"; +import moment from "moment"; +import { serverStore } from "../server/stores/server.js"; +import { siteStore } from "../server/stores/site.js"; +import { get } from "svelte/store"; + +import db from "./db/db.js"; + +const server = get(serverStore); +const siteData = get(siteStore); +const TRIGGERED = "TRIGGERED"; +const RESOLVED = "RESOLVED"; +const serverTriggers = server.triggers; + +function createJSONCommonAlert(monitor, config, alert) { + let siteURL = siteData.siteURL; + let id = monitor.tag + "-" + alert.id; + let alert_name = monitor.name + " " + alert.monitorStatus; + let severity = alert.monitorStatus === "DEGRADED" ? "warning" : "critical"; + let source = "Kener"; + let timestamp = new Date().toISOString(); + let description = config.description || "Monitor has failed"; + let status = alert.alertStatus; + let details = { + metric: monitor.name, + current_value: alert.healthChecks, + threshold: config.failureThreshold + }; + let actions = [ + { + text: "View Monitor", + url: siteURL + "/monitor-" + monitor.tag + } + ]; + return { + id, + alert_name, + severity, + status, + source, + timestamp, + description, + details, + actions + }; +} + +async function createGHIncident(monitor, alert, commonData) { + let payload = { + startDatetime: moment(alert.createAt).unix(), + title: commonData.alert_name, + tags: [monitor.tag], + impact: alert.monitorStatus, + body: commonData.description, + isIdentified: true + }; + + let description = commonData.description; + description = + description + + `\n\n ### Monitor Details \n\n - Monitor Name: ${monitor.name} \n- Incident Status: ${commonData.status} \n- Severity: ${commonData.severity} \n - Monitor Status: ${alert.monitorStatus} \n - Monitor Health Checks: ${alert.healthChecks} \n - Monitor Failure Threshold: ${commonData.details.threshold} \n\n ### Actions \n\n - [${commonData.actions[0].text}](${commonData.actions[0].url}) \n\n`; + + payload.body = description; + + let { title, body, githubLabels, error } = ParseIncidentPayload(payload); + if (error) { + return; + } + + githubLabels.push("auto"); + let resp = await CreateIssue(title, body, githubLabels); + + return GHIssueToKenerIncident(resp); +} + +async function closeGHIncident(alert) { + let incidentNumber = alert.incidentNumber; + let issue = await GetIncidentByNumber(incidentNumber); + if (issue === null) { + return; + } + let labels = issue.labels.map((label) => { + return label.name; + }); + labels = labels.filter((label) => label !== "resolved"); + labels.push("resolved"); + + let endDatetime = moment(alert.updatedAt).unix(); + let body = issue.body; + body = body.replace(/\[end_datetime:(\d+)\]/g, ""); + body = body.trim(); + body = body + " " + `[end_datetime:${endDatetime}]`; + + let resp = await UpdateIssueLabels(incidentNumber, labels, body); + if (resp === null) { + return; + } + await CloseIssue(incidentNumber); + return GHIssueToKenerIncident(resp); +} + +//add comment to incident +async function addCommentToIncident(alert, comment) { + let resp = await AddComment(alert.incidentNumber, comment); + return resp; +} + +function createClosureComment(alert, commonJSON) { + let comment = "The incident has been auto resolved"; + let downtimeDuration = moment(alert.updatedAt).diff(moment(alert.createdAt), "minutes"); + comment = comment + `\n\nTotal downtime: ` + downtimeDuration + ` minutes`; + return comment; +} + +async function alerting(monitor) { + if (serverTriggers === undefined) { + console.error("No triggers found in server configuration"); + return; + } + for (const key in monitor.alerts) { + if (Object.prototype.hasOwnProperty.call(monitor.alerts, key)) { + const alertConfig = monitor.alerts[key]; + const monitorStatus = key; + const failureThreshold = alertConfig.failureThreshold; + const successThreshold = alertConfig.successThreshold; + const monitorTag = monitor.tag; + const alertingChannels = alertConfig.triggers; + const createIncident = alertConfig.createIncident && siteData.hasGithub; + const allMonitorClients = []; + + if (alertingChannels.length > 0) { + for (let i = 0; i < alertingChannels.length; i++) { + const channelName = alertingChannels[i]; + const channel = serverTriggers.find((c) => c.name === channelName); + if (!channel) { + console.error( + `Triggers ${channelName} not found in server triggers for monitor ${monitorTag}` + ); + continue; + } + const notificationClient = new notification(channel, siteData, monitor); + allMonitorClients.push(notificationClient); + } + } + + let isAffected = await db.consecutivelyStatusFor( + monitorTag, + monitorStatus, + failureThreshold + ); + let alertExists = await db.alertExists(monitorTag, monitorStatus, TRIGGERED); + let activeAlert = null; + if (alertExists) { + activeAlert = await db.getActiveAlert(monitorTag, monitorStatus, TRIGGERED); + } + if (isAffected && !alertExists) { + activeAlert = await db.insertAlert({ + monitorTag: monitorTag, + monitorStatus: monitorStatus, + alertStatus: TRIGGERED, + healthChecks: failureThreshold + }); + let commonJSON = createJSONCommonAlert(monitor, alertConfig, activeAlert); + if (allMonitorClients.length > 0) { + for (let i = 0; i < allMonitorClients.length; i++) { + const client = allMonitorClients[i]; + client.send(commonJSON); + } + } + if (createIncident) { + let incident = await createGHIncident(monitor, activeAlert, commonJSON); + + if (!!incident) { + //send incident to incident channel + await db.addIncidentNumberToAlert(activeAlert.id, incident.incidentNumber); + } + } + } else if (isAffected && alertExists) { + await db.incrementAlertHealthChecks(activeAlert.id); + } else if (!isAffected && alertExists) { + let isUp = await db.consecutivelyStatusFor(monitorTag, "UP", successThreshold); + if (isUp) { + await db.updateAlertStatus(activeAlert.id, RESOLVED); + activeAlert.alertStatus = RESOLVED; + let commonJSON = createJSONCommonAlert(monitor, alertConfig, activeAlert); + if (allMonitorClients.length > 0) { + for (let i = 0; i < allMonitorClients.length; i++) { + const client = allMonitorClients[i]; + client.send(commonJSON); + } + } + if (!!activeAlert.incidentNumber) { + let comment = createClosureComment(activeAlert, commonJSON); + + try { + await addCommentToIncident(activeAlert, comment); + await closeGHIncident(activeAlert); + } catch (error) { + console.log(error); + } + } + } + } + } + } +} + +export default alerting; diff --git a/src/lib/server/check.js b/src/lib/server/check.js index 9e6d3376..17dca67f 100644 --- a/src/lib/server/check.js +++ b/src/lib/server/check.js @@ -3,7 +3,6 @@ import { ENV } from "./constants.js"; import { IsStringURLSafe } from "./tool.js"; import dotenv from "dotenv"; dotenv.config(); -import fs from "fs-extra"; let STATUS_OK = false; if (ENV === undefined) { diff --git a/src/lib/server/constants.js b/src/lib/server/constants.js index c927015b..ffeecd83 100644 --- a/src/lib/server/constants.js +++ b/src/lib/server/constants.js @@ -14,5 +14,86 @@ const AnalyticsProviders = { AMPLITUDE: "https://unpkg.com/@analytics/amplitude@0.1.3/dist/@analytics/amplitude.min.js", MIXPANEL: "https://unpkg.com/@analytics/mixpanel@0.4.0/dist/@analytics/mixpanel.min.js" }; +const AllRecordTypes = { + A: 1, + NS: 2, + MD: 3, + MF: 4, + CNAME: 5, + SOA: 6, + MB: 7, + MG: 8, + MR: 9, + NULL: 10, + WKS: 11, + PTR: 12, + HINFO: 13, + MINFO: 14, + MX: 15, + TXT: 16, + RP: 17, + AFSDB: 18, + X25: 19, + ISDN: 20, + RT: 21, + NSAP: 22, + NSAP_PTR: 23, + SIG: 24, + KEY: 25, + PX: 26, + GPOS: 27, + AAAA: 28, + LOC: 29, + NXT: 30, + EID: 31, + NIMLOC: 32, + SRV: 33, + ATMA: 34, + NAPTR: 35, + KX: 36, + CERT: 37, + A6: 38, + DNAME: 39, + SINK: 40, + OPT: 41, + APL: 42, + DS: 43, + SSHFP: 44, + IPSECKEY: 45, + RRSIG: 46, + NSEC: 47, + DNSKEY: 48, + DHCID: 49, + NSEC3: 50, + NSEC3PARAM: 51, + TLSA: 52, + SMIMEA: 53, + HIP: 55, + NINFO: 56, + RKEY: 57, + TALINK: 58, + CDS: 59, + CDNSKEY: 60, + OPENPGPKEY: 61, + CSYNC: 62, + SPF: 99, + UINFO: 100, + UID: 101, + GID: 102, + UNSPEC: 103, + NID: 104, + L32: 105, + L64: 106, + LP: 107, + EUI48: 108, + EUI64: 109, + TKEY: 249, + TSIG: 250, + IXFR: 251, + AXFR: 252, + MAILB: 253, + MAILA: 254, + ANY: 255 +}; // Export the constants -export { MONITOR, UP, DOWN, SITE, DEGRADED, API_TIMEOUT, ENV, AnalyticsProviders }; +export { MONITOR, UP, DOWN, SITE, DEGRADED, API_TIMEOUT, ENV, AnalyticsProviders, AllRecordTypes }; diff --git a/src/lib/server/cron-minute.js b/src/lib/server/cron-minute.js index 9f4ed3f7..c6c94b6b 100644 --- a/src/lib/server/cron-minute.js +++ b/src/lib/server/cron-minute.js @@ -1,29 +1,51 @@ // @ts-nocheck import axios from "axios"; import ping from "ping"; -import fs from "fs-extra"; import { UP, DOWN, DEGRADED } from "./constants.js"; import { GetNowTimestampUTC, GetMinuteStartNowTimestampUTC, - GetMinuteStartTimestampUTC + GetMinuteStartTimestampUTC, + ReplaceAllOccurrences, + GetRequiredSecrets, + ValidateMonitorAlerts } from "./tool.js"; -import { GetIncidents, GetEndTimeFromBody, GetStartTimeFromBody, CloseIssue } from "./github.js"; +import { + GetIncidentsManual, + GetEndTimeFromBody, + GetStartTimeFromBody, + CloseIssue +} from "./github.js"; import Randomstring from "randomstring"; +import alerting from "./alerting.js"; import Queue from "queue"; import dotenv from "dotenv"; import path from "path"; +import db from "./db/db.js"; +import notification from "./notification/notif.js"; +import DNSResolver from "./dns.js"; + dotenv.config(); -const Kener_folder = "./database"; +const REALTIME = "realtime"; +const TIMEOUT = "timeout"; +const ERROR = "error"; +const MANUAL = "manual"; + const apiQueue = new Queue({ concurrency: 10, // Number of tasks that can run concurrently timeout: 10000, // Timeout in ms after which a task will be considered as failed (optional) autostart: true // Automatically start the queue (optional) }); +const alertingQueue = new Queue({ + concurrency: 10, // Number of tasks that can run concurrently + timeout: 10000, // Timeout in ms after which a task will be considered as failed (optional) + autostart: true // Automatically start the queue (optional) +}); + async function manualIncident(monitor, githubConfig) { - let incidentsResp = await GetIncidents(monitor.tag, githubConfig, "open"); + let incidentsResp = await GetIncidentsManual(monitor.tag, "open"); let manualData = {}; if (incidentsResp.length == 0) { @@ -33,6 +55,7 @@ async function manualIncident(monitor, githubConfig) { let timeDownEnd = 0; let timeDegradedStart = +Infinity; let timeDegradedEnd = 0; + for (let i = 0; i < incidentsResp.length; i++) { const incident = incidentsResp[i]; const incidentNumber = incident.number; @@ -58,7 +81,7 @@ async function manualIncident(monitor, githubConfig) { if (end_time <= GetNowTimestampUTC() && incident.state === "open") { //close the issue after 30 secs setTimeout(async () => { - await CloseIssue(githubConfig, incidentNumber); + await CloseIssue(incidentNumber); }, 30000); } } else { @@ -85,7 +108,7 @@ async function manualIncident(monitor, githubConfig) { manualData[i] = { status: DEGRADED, latency: 0, - type: "manual" + type: MANUAL }; } @@ -95,18 +118,15 @@ async function manualIncident(monitor, githubConfig) { manualData[i] = { status: DOWN, latency: 0, - type: "manual" + type: MANUAL }; } return manualData; } -function replaceAllOccurrences(originalString, searchString, replacement) { - const regex = new RegExp(`\\${searchString}`, "g"); - const replacedString = originalString.replace(regex, replacement); - return replacedString; -} const pingCall = async (hostsV4, hostsV6) => { + if (hostsV4 === undefined) hostsV4 = []; + if (hostsV6 === undefined) hostsV6 = []; let alive = true; let latencyTotal = 0; let countHosts = hostsV4.length + hostsV6.length; @@ -140,25 +160,25 @@ const pingCall = async (hostsV4, hostsV6) => { return { status: alive ? UP : DOWN, latency: parseInt(latencyTotal / countHosts), - type: "realtime" + type: REALTIME }; }; const apiCall = async (envSecrets, url, method, headers, body, timeout, monitorEval) => { let axiosHeaders = {}; - axiosHeaders["User-Agent"] = "Kener/0.0.1"; + axiosHeaders["User-Agent"] = "Kener/2.0.0"; axiosHeaders["Accept"] = "*/*"; const start = Date.now(); //replace all secrets for (let i = 0; i < envSecrets.length; i++) { const secret = envSecrets[i]; if (!!body) { - body = replaceAllOccurrences(body, secret.find, secret.replace); + body = ReplaceAllOccurrences(body, secret.find, secret.replace); } if (!!url) { - url = replaceAllOccurrences(url, secret.find, secret.replace); + url = ReplaceAllOccurrences(url, secret.find, secret.replace); } if (!!headers) { - headers = replaceAllOccurrences(headers, secret.find, secret.replace); + headers = ReplaceAllOccurrences(headers, secret.find, secret.replace); } } if (!!headers) { @@ -190,16 +210,21 @@ const apiCall = async (envSecrets, url, method, headers, body, timeout, monitorE if (err.message.startsWith("timeout of") && err.message.endsWith("exceeded")) { timeoutError = true; } - + // console.log(">>>>>>---- cron-minute:21224 ", err.response); if (err.response !== undefined && err.response.status !== undefined) { statusCode = err.response.status; } if (err.response !== undefined && err.response.data !== undefined) { resp = err.response.data; + } else { + resp = JSON.stringify(resp); } } finally { const end = Date.now(); latency = end - start; + if (resp === undefined || resp === null) { + resp = ""; + } } resp = Buffer.from(resp).toString("base64"); let evalResp = eval(monitorEval + `(${statusCode}, ${latency}, "${resp}")`); @@ -207,7 +232,7 @@ const apiCall = async (envSecrets, url, method, headers, body, timeout, monitorE evalResp = { status: DOWN, latency: latency, - type: "error" + type: ERROR }; } else if ( evalResp.status === undefined || @@ -217,16 +242,16 @@ const apiCall = async (envSecrets, url, method, headers, body, timeout, monitorE evalResp = { status: DOWN, latency: latency, - type: "error" + type: ERROR }; } else { - evalResp.type = "realtime"; + evalResp.type = REALTIME; } let toWrite = { status: DOWN, latency: latency, - type: "error" + type: ERROR }; if (evalResp.status !== undefined && evalResp.status !== null) { toWrite.status = evalResp.status; @@ -238,65 +263,69 @@ const apiCall = async (envSecrets, url, method, headers, body, timeout, monitorE toWrite.type = evalResp.type; } if (timeoutError) { - toWrite.type = "timeout"; + toWrite.type = TIMEOUT; } return toWrite; }; -const getWebhookData = async (monitor) => { - let originalData = {}; - - let files = fs.readdirSync(Kener_folder); - files = files.filter((file) => file.startsWith(monitor.folderName + ".webhook")); - for (let i = 0; i < files.length; i++) { - const file = files[i]; - let webhookData = {}; - try { - let fd = fs.readFileSync(Kener_folder + "/" + file, "utf8"); - webhookData = JSON.parse(fd); - for (const timestamp in webhookData) { - originalData[timestamp] = webhookData[timestamp]; - } - //delete the file - fs.unlinkSync(Kener_folder + "/" + file); - } catch (error) { - console.error(error); - } - } - return originalData; -}; -const updateDayData = async (mergedData, startOfMinute, monitor) => { - let dayData = JSON.parse(fs.readFileSync(monitor.path0Day, "utf8")); - for (const timestamp in mergedData) { - dayData[timestamp] = mergedData[timestamp]; - } +async function dsnChecker(dnsResolver, host, recordType, matchType, values) { + try { + let queryStartTime = Date.now(); + let dnsRes = await dnsResolver.getRecord(host, recordType); + let latency = Date.now() - queryStartTime; - let since = 24 * 91; - let mxBackDate = startOfMinute - since * 3600; - let _0Day = {}; - for (const ts in dayData) { - const element = dayData[ts]; - if (ts >= mxBackDate) { - _0Day[ts] = element; + if (dnsRes[recordType] === undefined) { + return { + status: DOWN, + latency: latency, + type: REALTIME + }; + } + let data = dnsRes[recordType]; + let dnsData = data.map((d) => d.data); + if (matchType === "ALL") { + for (let i = 0; i < values.length; i++) { + if (dnsData.indexOf(values[i].trim()) === -1) { + return { + status: DOWN, + latency: latency, + type: REALTIME + }; + } + } + return { + status: UP, + latency: latency, + type: REALTIME + }; + } else if (matchType === "ANY") { + for (let i = 0; i < values.length; i++) { + if (dnsData.indexOf(values[i].trim()) !== -1) { + return { + status: UP, + latency: latency, + type: REALTIME + }; + } + } + return { + status: DOWN, + latency: latency, + type: REALTIME + }; } - } - - //sort the keys - let keys = Object.keys(_0Day); - keys.sort(); - let sortedDay0 = {}; - keys.reverse().forEach((key) => { - sortedDay0[key] = _0Day[key]; - }); - try { - fs.writeFileSync(monitor.path0Day, JSON.stringify(sortedDay0, null, 2)); } catch (error) { - console.error(error); + console.log("Error in dnsChecker", error); + return { + status: DOWN, + latency: 0, + type: REALTIME + }; } -}; +} -const Minuter = async (envSecrets, monitor, githubConfig) => { +const Minuter = async (monitor, githubConfig) => { if (apiQueue.length > 0) { console.log("Queue length is " + apiQueue.length); } @@ -304,6 +333,11 @@ const Minuter = async (envSecrets, monitor, githubConfig) => { let pingData = {}; let webhookData = {}; let manualData = {}; + let dnsData = {}; + let envSecrets = GetRequiredSecrets( + `${monitor.url} ${monitor.body} ${JSON.stringify(monitor.headers)}` + ); + const startOfMinute = GetMinuteStartNowTimestampUTC(); if (monitor.hasAPI) { @@ -317,7 +351,7 @@ const Minuter = async (envSecrets, monitor, githubConfig) => { monitor.api.eval ); apiData[startOfMinute] = apiResponse; - if (apiResponse.type === "timeout") { + if (apiResponse.type === TIMEOUT) { console.log( "Retrying api call for " + monitor.name + " at " + startOfMinute + " due to timeout" ); @@ -332,13 +366,13 @@ const Minuter = async (envSecrets, monitor, githubConfig) => { monitor.api.timeout, monitor.api.eval ).then(async (data) => { - let day0 = {}; - day0[startOfMinute] = data; - fs.writeFileSync( - Kener_folder + - `/${monitor.folderName}.webhook.${Randomstring.generate()}.json`, - JSON.stringify(day0, null, 2) - ); + await db.insertData({ + monitorTag: monitor.tag, + timestamp: startOfMinute, + status: data.status, + latency: data.latency, + type: data.type + }); cb(); }); }); @@ -350,7 +384,18 @@ const Minuter = async (envSecrets, monitor, githubConfig) => { pingData[startOfMinute] = pingResponse; } - webhookData = await getWebhookData(monitor); + if (monitor.hasDNS) { + const dnsResolver = new DNSResolver(monitor.dns.nameServer); + let dnsResponse = await dsnChecker( + dnsResolver, + monitor.dns.host, + monitor.dns.lookupRecord, + monitor.dns.matchType, + monitor.dns.values + ); + dnsData[startOfMinute] = dnsResponse; + } + manualData = await manualIncident(monitor, githubConfig); //merge noData, apiData, webhookData, dayData let mergedData = {}; @@ -371,21 +416,44 @@ const Minuter = async (envSecrets, monitor, githubConfig) => { for (const timestamp in apiData) { mergedData[timestamp] = apiData[timestamp]; } - for (const timestamp in webhookData) { - mergedData[timestamp] = webhookData[timestamp]; - } + for (const timestamp in manualData) { mergedData[timestamp] = manualData[timestamp]; } - //update day data - await updateDayData(mergedData, startOfMinute, monitor); + for (const timestamp in dnsData) { + mergedData[timestamp] = dnsData[timestamp]; + } + + for (const timestamp in mergedData) { + const element = mergedData[timestamp]; + db.insertData({ + monitorTag: monitor.tag, + timestamp: parseInt(timestamp), + status: element.status, + latency: element.latency, + type: element.type + }); + } + if (monitor.alerts && ValidateMonitorAlerts(monitor.alerts)) { + alertingQueue.push(async (cb) => { + setTimeout(async () => { + await alerting(monitor); + cb(); + }, 1042); + }); + } }; apiQueue.start((err) => { if (err) { console.error("Error occurred:", err); - } else { - console.log("All tasks completed"); + process.exit(1); + } +}); +alertingQueue.start((err) => { + if (err) { + console.error("Error occurred:", err); + process.exit(1); } }); export { Minuter }; diff --git a/src/lib/server/db/db.js b/src/lib/server/db/db.js new file mode 100644 index 00000000..6dbd77c4 --- /dev/null +++ b/src/lib/server/db/db.js @@ -0,0 +1,64 @@ +// @ts-nocheck +import Sqlite from "./sqlite.js"; +import Postgres from "./postgres.js"; +import migration2 from "./migration2.js"; +import { serverStore } from "../stores/server.js"; +import { get } from "svelte/store"; + +let instance = null; + +const server = get(serverStore); +let database = server.database; +if (database === undefined) { + database = { + sqlite: { + dbName: "kener.db" + } + }; +} +const supportedDatabases = ["sqlite", "postgres"]; +const dbType = Object.keys(database)[0] || "sqlite"; +const dbConfig = database[dbType]; + +if (!supportedDatabases.includes(dbType)) { + console.error(`Database type ${dbType} is not supported`); + process.exit(1); +} + +if (dbType === "sqlite") { + if (dbConfig.dbName === undefined) { + console.error("dbName name is required for sqlite database"); + process.exit(1); + } + instance = new Sqlite({ + dbName: `./database/${dbConfig.dbName}` + }); +} else if (dbType == "postgres") { + if (dbConfig.user === undefined) { + console.error("user is required for postgres database"); + process.exit(1); + } + if (dbConfig.host === undefined) { + console.error("host is required for postgres database"); + process.exit(1); + } + if (dbConfig.port === undefined) { + console.error("port is required for postgres database"); + process.exit(1); + } + if (dbConfig.database === undefined) { + console.error("database is required for postgres database"); + process.exit(1); + } + if (dbConfig.password === undefined) { + console.error("password is required for postgres database"); + process.exit(1); + } + instance = new Postgres(dbConfig); +} + +migration2(instance, "./database"); + +//create anonymous function to call the init function + +export default instance; diff --git a/src/lib/server/db/migration2.js b/src/lib/server/db/migration2.js new file mode 100644 index 00000000..548123a0 --- /dev/null +++ b/src/lib/server/db/migration2.js @@ -0,0 +1,49 @@ +// @ts-nocheck +import fs from "fs-extra"; + +/** + * @param {string} _path + */ +export default async function migration2(db, _path) { + //read any file that ends with 0day.utc.json + const files = await fs.readdir(_path); + const data = {}; + for (const file of files) { + if (file.endsWith(".0day.utc.json")) { + const content = await fs.readJson(`${_path}/${file}`); + //get the first part + const parts = file.split("."); + const key = parts[0]; + data[key] = content; + } + } + + if (Object.keys(data).length === 0) { + return; + } + + console.log("Migrating data to the database"); + + for (const tag in data) { + if (Object.prototype.hasOwnProperty.call(data, tag)) { + const content = data[tag]; + + for (const ts in content) { + if (Object.prototype.hasOwnProperty.call(content, ts)) { + const element = content[ts]; + const insertData = { + monitorTag: tag, + timestamp: ts, + status: element.status, + latency: element.latency, + type: element.type + }; + await db.insertData(insertData); + } + } + + //remove the file + await fs.remove(`${_path}/${tag}.0day.utc.json`); + } + } +} diff --git a/src/lib/server/db/postgres.js b/src/lib/server/db/postgres.js new file mode 100644 index 00000000..93c6353f --- /dev/null +++ b/src/lib/server/db/postgres.js @@ -0,0 +1,297 @@ +// @ts-nocheck +import pkg from "pg"; +const { Pool } = pkg; + +import { + GetMinuteStartNowTimestampUTC, + GetRequiredSecrets, + ReplaceAllOccurrences +} from "../tool.js"; + +class Postgres { + pool; + + constructor(opts) { + const defaultOptions = {}; + + let strOpts = JSON.stringify(opts); + let envSecrets = GetRequiredSecrets(JSON.stringify(opts)); + for (let i = 0; i < envSecrets.length; i++) { + strOpts = ReplaceAllOccurrences(strOpts, envSecrets[i].find, envSecrets[i].replace); + } + + opts = JSON.parse(strOpts); + + defaultOptions.connectionString = `postgres://${opts.user}:${opts.password}@${opts.host}:${opts.port}/${opts.database}`; + + const dbOptions = { ...defaultOptions, ...opts.dbOptions }; + this.pool = new Pool(dbOptions); + + // Call init + this.init(); + } + + async init() { + const client = await this.pool.connect(); + try { + await client.query(` + CREATE TABLE IF NOT EXISTS "MonitoringData" ( + "monitorTag" TEXT NOT NULL, + "timestamp" BIGINT NOT NULL, + "status" TEXT, + "latency" REAL, + "type" TEXT, + PRIMARY KEY ("monitorTag", "timestamp") + ); + + CREATE TABLE IF NOT EXISTS "MonitorAlerts" ( + id SERIAL PRIMARY KEY, + "monitorTag" TEXT NOT NULL, + "monitorStatus" TEXT NOT NULL, + "alertStatus" TEXT NOT NULL, + "healthChecks" INTEGER NOT NULL, + "incidentNumber" INTEGER DEFAULT 0, + "createdAt" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_monitor_tag_created_at + ON "MonitorAlerts" ("monitorTag", "createdAt"); + `); + } finally { + client.release(); + } + } + + async insertData(data) { + await this.pool.query( + ` + INSERT INTO "MonitoringData" ("monitorTag", "timestamp", "status", "latency", "type") + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT ("monitorTag", "timestamp") DO UPDATE SET + "status" = EXCLUDED.status, + "latency" = EXCLUDED.latency, + "type" = EXCLUDED.type; + `, + [data.monitorTag, data.timestamp, data.status, data.latency, data.type] + ); + } + + async getData(monitorTag, start, end) { + const { rows } = await this.pool.query( + ` + SELECT * FROM "MonitoringData" + WHERE "monitorTag" = $1 AND "timestamp" >= $2 AND "timestamp" <= $3 + ORDER BY "timestamp" ASC; + `, + [monitorTag, start, end] + ); + return rows; + } + + async getLatestData(monitorTag) { + const { rows } = await this.pool.query( + ` + SELECT * FROM "MonitoringData" + WHERE "monitorTag" = $1 + ORDER BY "timestamp" DESC + LIMIT 1; + `, + [monitorTag] + ); + return rows[0]; + } + + async getAggregatedData(monitorTag, start, end) { + const { rows } = await this.pool.query( + ` + SELECT + COUNT(*) AS total, + SUM(CASE WHEN "status" = 'UP' THEN 1 ELSE 0 END) AS "UP", + SUM(CASE WHEN "status" = 'DOWN' THEN 1 ELSE 0 END) AS "DOWN", + SUM(CASE WHEN "status" = 'DEGRADED' THEN 1 ELSE 0 END) AS "DEGRADED", + AVG("latency") AS "avgLatency", + MAX("latency") AS "maxLatency", + MIN("latency") AS "minLatency" + FROM "MonitoringData" + WHERE "monitorTag" = $1 AND "timestamp" >= $2 AND "timestamp" <= $3; + `, + [monitorTag, start, end] + ); + return rows[0]; + } + async getDataGroupByDayAlternative(monitorTag, start, end, timezoneOffsetMinutes = 0) { + const offsetMinutes = Number(timezoneOffsetMinutes); + if (isNaN(offsetMinutes)) { + throw new Error( + "Invalid timezone offset. Must be a number representing minutes from UTC." + ); + } + // Calculate the offset in seconds + const offsetSeconds = offsetMinutes * 60; + + const { rows } = await this.pool.query( + ` + SELECT + timestamp, + status, + latency + FROM "MonitoringData" + WHERE "monitorTag" = $1 AND timestamp >= $2 AND timestamp <= $3 + ORDER BY timestamp ASC; + `, + [monitorTag, start, end] + ); + let rawData = rows; + const groupedData = rawData.reduce((acc, row) => { + // Calculate day group considering timezone offset + const dayGroup = Math.floor((Number(row.timestamp) + offsetSeconds) / 86400); + if (!acc[dayGroup]) { + acc[dayGroup] = { + timestamp: dayGroup * 86400 - offsetSeconds, // start of day in UTC + total: 0, + UP: 0, + DOWN: 0, + DEGRADED: 0, + latencySum: 0, + latencies: [] + }; + } + + const group = acc[dayGroup]; + group.total++; + group[row.status]++; + group.latencySum += row.latency; + group.latencies.push(row.latency); + + return acc; + }, {}); + + // Transform grouped data to final format + return Object.values(groupedData) + .map((group) => ({ + timestamp: group.timestamp, + total: group.total, + UP: group.UP, + DOWN: group.DOWN, + DEGRADED: group.DEGRADED, + avgLatency: + group.total > 0 ? Number((group.latencySum / group.total).toFixed(3)) : null, + maxLatency: + group.latencies.length > 0 + ? Number(Math.max(...group.latencies).toFixed(3)) + : null, + minLatency: + group.latencies.length > 0 + ? Number(Math.min(...group.latencies).toFixed(3)) + : null + })) + .sort((a, b) => a.timestamp - b.timestamp); + } + + async background() { + const ninetyDaysAgo = GetMinuteStartNowTimestampUTC() - 86400 * 90; + await this.pool.query( + ` + DELETE FROM "MonitoringData" + WHERE timestamp < $1; + `, + [ninetyDaysAgo] + ); + } + async consecutivelyStatusFor(monitorTag, status, lastX) { + const { rows } = await this.pool.query( + ` + SELECT + COUNT(*) AS total, + SUM(CASE WHEN status = $2 THEN 1 ELSE 0 END) AS matches + FROM ( + SELECT * FROM "MonitoringData" + WHERE "monitorTag" = $1 + ORDER BY timestamp DESC + LIMIT $3 + ) AS subquery; + `, + [monitorTag, status, lastX] + ); + return rows[0].total === rows[0].matches; + } + async insertAlert(data) { + if (await this.alertExists(data.monitorTag, data.monitorStatus, data.alertStatus)) { + return; + } + + const { rows } = await this.pool.query( + ` + INSERT INTO "MonitorAlerts" ("monitorTag", "monitorStatus", "alertStatus", "healthChecks") + VALUES ($1, $2, $3, $4) + RETURNING *; + `, + [data.monitorTag, data.monitorStatus, data.alertStatus, data.healthChecks] + ); + return rows[0]; + } + + //check if alert exists given monitorTag, monitorStatus, alertStatus + async alertExists(monitorTag, monitorStatus, alertStatus) { + const { rows } = await this.pool.query( + ` + SELECT COUNT(*) AS count + FROM "MonitorAlerts" + WHERE "monitorTag" = $1 AND "monitorStatus" = $2 AND "alertStatus" = $3; + `, + [monitorTag, monitorStatus, alertStatus] + ); + return rows[0].count > 0; + } + + //return active alert for a monitorTag, monitorStatus, alertStatus = ACTIVE + async getActiveAlert(monitorTag, monitorStatus, alertStatus) { + const { rows } = await this.pool.query( + ` + SELECT * FROM "MonitorAlerts" + WHERE "monitorTag" = $1 AND "monitorStatus" = $2 AND "alertStatus" = $3; + `, + [monitorTag, monitorStatus, alertStatus] + ); + return rows[0]; + } + + async updateAlertStatus(id, status) { + await this.pool.query( + ` + UPDATE "MonitorAlerts" + SET "alertStatus" = $1, "updatedAt" = CURRENT_TIMESTAMP + WHERE id = $2; + `, + [status, id] + ); + } + + async incrementAlertHealthChecks(id) { + await this.pool.query( + ` + UPDATE "MonitorAlerts" + SET "healthChecks" = "healthChecks" + 1, "updatedAt" = CURRENT_TIMESTAMP WHERE id = $1; + `, + [id] + ); + } + + async addIncidentNumberToAlert(id, incidentNumber) { + await this.pool.query( + ` + UPDATE "MonitorAlerts" + SET "incidentNumber" = $1, "updatedAt" = CURRENT_TIMESTAMP + WHERE id = $2; + `, + [incidentNumber, id] + ); + } + + close() { + this.pool.end(); + } +} + +export default Postgres; diff --git a/src/lib/server/db/sqlite.js b/src/lib/server/db/sqlite.js new file mode 100644 index 00000000..afdb131c --- /dev/null +++ b/src/lib/server/db/sqlite.js @@ -0,0 +1,273 @@ +// @ts-nocheck +import Database from "better-sqlite3"; +import { GetMinuteStartNowTimestampUTC } from "../tool.js"; +class Sqlite { + db; + constructor(opts) { + let defaultOptions = { + readonly: false, + fileMustExist: false, + timeout: 5000 + }; + //merge opts.dbOptions with defaultOptions + let dbOptions = Object.assign(defaultOptions, opts.dbOptions); + this.db = new Database(opts.dbName, dbOptions); + this.db.pragma("journal_mode = WAL"); + + //call init + this.init(); + } + + async init() { + this.db.exec(` + + CREATE TABLE IF NOT EXISTS MonitoringData ( + monitorTag TEXT NOT NULL, + timestamp INTEGER NOT NULL, + status TEXT, + latency REAL, + type TEXT, + PRIMARY KEY (monitorTag, timestamp) + ); + + CREATE TABLE IF NOT EXISTS MonitorAlerts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + monitorTag TEXT NOT NULL, + monitorStatus TEXT NOT NULL, + alertStatus TEXT NOT NULL, + healthChecks INTEGER NOT NULL, + incidentNumber INTEGER DEFAULT 0, + createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, + updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + -- Create index on monitorTag and createdAt for MonitorAlerts + CREATE INDEX IF NOT EXISTS idx_monitor_tag_created_at + ON MonitorAlerts (monitorTag, createdAt); + `); + } + + async insertData(data) { + let stmt = this.db.prepare(` + INSERT INTO MonitoringData (monitorTag, timestamp, status, latency, type) + VALUES (@monitorTag, @timestamp, @status, @latency, @type) + ON CONFLICT(monitorTag, timestamp) DO UPDATE SET + status = excluded.status, + latency = excluded.latency, + type = excluded.type; + `); + stmt.run(data); + } + + //given monitorTag, start and end timestamp in utc seconds return data + async getData(monitorTag, start, end) { + let stmt = this.db.prepare(` + SELECT * FROM MonitoringData + WHERE monitorTag = @monitorTag AND timestamp >= @start AND timestamp <= @end + ORDER BY timestamp ASC; + `); + return stmt.all({ monitorTag, start, end }); + } + + //get latest data for a monitorTag + async getLatestData(monitorTag) { + let stmt = this.db.prepare(` + SELECT * FROM MonitoringData + WHERE monitorTag = @monitorTag + ORDER BY timestamp DESC + LIMIT 1; + `); + return stmt.get({ monitorTag }); + } + + //given monitorTag, start and end timestamp in utc seconds return total degraded, up, down, avg(latency), max(latency), min(latency) + async getAggregatedData(monitorTag, start, end) { + let stmt = this.db.prepare(` + SELECT + COUNT(*) AS total, + SUM(CASE WHEN status = 'UP' THEN 1 ELSE 0 END) AS UP, + SUM(CASE WHEN status = 'DOWN' THEN 1 ELSE 0 END) AS DOWN, + SUM(CASE WHEN status = 'DEGRADED' THEN 1 ELSE 0 END) AS DEGRADED, + AVG(latency) AS avgLatency, + MAX(latency) AS maxLatency, + MIN(latency) AS minLatency + FROM MonitoringData + WHERE monitorTag = @monitorTag AND timestamp >= @start AND timestamp <= @end; + `); + return stmt.get({ monitorTag, start, end }); + } + + async getDataGroupByDayAlternative(monitorTag, start, end, timezoneOffsetMinutes = 0) { + // Validate and normalize timezone offset + const offsetMinutes = Number(timezoneOffsetMinutes); + if (isNaN(offsetMinutes)) { + throw new Error( + "Invalid timezone offset. Must be a number representing minutes from UTC." + ); + } + + // Calculate the offset in seconds + const offsetSeconds = offsetMinutes * 60; + + // Fetch all raw data + let stmt = this.db.prepare(` + SELECT + timestamp, + status, + latency + FROM MonitoringData + WHERE monitorTag = ? AND timestamp >= ? AND timestamp <= ? + ORDER BY timestamp ASC; + `); + + const rawData = stmt.all(monitorTag, start, end); //{ timestamp: 1732900380, status: 'UP', latency: 42 } + // Group manually in JavaScript + const groupedData = rawData.reduce((acc, row) => { + // Calculate day group considering timezone offset + const dayGroup = Math.floor((row.timestamp + offsetSeconds) / 86400); + if (!acc[dayGroup]) { + acc[dayGroup] = { + timestamp: dayGroup * 86400 - offsetSeconds, // start of day in UTC + total: 0, + UP: 0, + DOWN: 0, + DEGRADED: 0, + latencySum: 0, + latencies: [] + }; + } + + const group = acc[dayGroup]; + group.total++; + group[row.status]++; + group.latencySum += row.latency; + group.latencies.push(row.latency); + + return acc; + }, {}); + + // Transform grouped data to final format + return Object.values(groupedData) + .map((group) => ({ + timestamp: group.timestamp, + total: group.total, + UP: group.UP, + DOWN: group.DOWN, + DEGRADED: group.DEGRADED, + avgLatency: + group.total > 0 ? Number((group.latencySum / group.total).toFixed(3)) : null, + maxLatency: + group.latencies.length > 0 + ? Number(Math.max(...group.latencies).toFixed(3)) + : null, + minLatency: + group.latencies.length > 0 + ? Number(Math.min(...group.latencies).toFixed(3)) + : null + })) + .sort((a, b) => a.timestamp - b.timestamp); + } + + async background() { + //clear data older than 90 days + let ninetyDaysAgo = GetMinuteStartNowTimestampUTC() - 86400 * 90; + let stmt = this.db.prepare(` + DELETE FROM MonitoringData + WHERE timestamp < @ninetyDaysAgo; + `); + } + + async consecutivelyStatusFor(monitorTag, status, lastX) { + let stmt = this.db.prepare(` + SELECT + CASE + WHEN COUNT(*) <= SUM(CASE WHEN status = @status THEN 1 ELSE 0 END) THEN 1 + ELSE 0 + END AS isAffected + FROM ( + SELECT * + FROM MonitoringData + where monitorTag = @monitorTag + ORDER BY timestamp DESC + LIMIT @lastX + ) AS last_four; + `); + return stmt.get({ monitorTag, status, lastX }).isAffected == 1; + } + + //insert alert + async insertAlert(data) { + //if alert exists return + + if (await this.alertExists(data.monitorTag, data.monitorStatus, data.alertStatus)) { + return; + } + + let stmt = this.db.prepare(` + INSERT INTO MonitorAlerts (monitorTag, monitorStatus, alertStatus, healthChecks) + VALUES (@monitorTag, @monitorStatus, @alertStatus, @healthChecks); + `); + let x = stmt.run(data); + + // Return the created row + return await this.getActiveAlert(data.monitorTag, data.monitorStatus, data.alertStatus); + } + + //check if alert exists given monitorTag, monitorStatus, alertStatus + async alertExists(monitorTag, monitorStatus, alertStatus) { + let stmt = this.db.prepare(` + SELECT COUNT(*) AS count + FROM MonitorAlerts + WHERE monitorTag = @monitorTag AND monitorStatus = @monitorStatus AND alertStatus = @alertStatus; + `); + + let res = stmt.get({ monitorTag, monitorStatus, alertStatus }); + return res.count > 0; + } + + //return active alert for a monitorTag, monitorStatus, alertStatus = ACTIVE + async getActiveAlert(monitorTag, monitorStatus, alertStatus) { + let stmt = this.db.prepare(` + SELECT * FROM MonitorAlerts + WHERE monitorTag = @monitorTag AND monitorStatus = @monitorStatus AND alertStatus = @alertStatus; + `); + return stmt.get({ monitorTag, monitorStatus, alertStatus }); + } + + //update alert to inactive given monitorTag, monitorStatus, given id + async updateAlertStatus(id, status) { + let stmt = this.db.prepare(` + UPDATE MonitorAlerts + SET alertStatus = @status, updatedAt = CURRENT_TIMESTAMP + WHERE id = @id; + `); + stmt.run({ id, status }); + } + + //increment healthChecks for an alert given id + async incrementAlertHealthChecks(id) { + let stmt = this.db.prepare(` + UPDATE MonitorAlerts + SET healthChecks = healthChecks + 1, updatedAt = CURRENT_TIMESTAMP + WHERE id = @id; + `); + stmt.run({ id }); + } + + //add incidentNumber to an alert given id + async addIncidentNumberToAlert(id, incidentNumber) { + let stmt = this.db.prepare(` + UPDATE MonitorAlerts + SET incidentNumber = @incidentNumber, updatedAt = CURRENT_TIMESTAMP + WHERE id = @id; + `); + stmt.run({ id, incidentNumber }); + } + + //close + close() { + this.db.close(); + } +} + +export default Sqlite; diff --git a/src/lib/server/dns.js b/src/lib/server/dns.js new file mode 100644 index 00000000..4291dbb6 --- /dev/null +++ b/src/lib/server/dns.js @@ -0,0 +1,111 @@ +// @ts-nocheck +import dns2 from "dns2"; +import dgram from "dgram"; +import { AllRecordTypes } from "./constants.js"; + +class DNSResolver { + constructor(nameserver = "8.8.8.8") { + this.nameserver = nameserver; + this.socket = dgram.createSocket("udp4"); + } + + createQuery(domain, type) { + const packet = new dns2.Packet(); + packet.header.id = 1; + packet.header.rd = 1; + packet.questions.push({ + name: domain, + type: AllRecordTypes[type], + class: 1 + }); + return packet; + } + + async query(domain, recordType) { + return new Promise((resolve, reject) => { + const query = this.createQuery(domain, recordType); + const buffer = query.toBuffer(); + + this.socket.on("message", (message) => { + const response = dns2.Packet.parse(message); + resolve(response); + }); + + this.socket.send(buffer, 0, buffer.length, 53, this.nameserver, (err) => { + if (err) reject(err); + }); + }); + } + + extractData(answer, recordType) { + switch (recordType) { + case "A": + case "AAAA": + return answer.address; + case "NS": + return answer.ns; + case "CNAME": + return answer.domain; + case "MX": + return { + exchange: answer.exchange, + priority: answer.priority + }; + default: + return answer.data; + } + } + + async getRecord(domain, recordType) { + const results = {}; + + try { + const response = await this.query(domain, recordType); + results[recordType] = response.answers.map((answer) => ({ + name: answer.name, + type: recordType, + ttl: answer.ttl, + data: this.extractData(answer, recordType) + })); + return results; + } catch (error) { + console.error("Error querying DNS records:", error); + throw error; + } finally { + this.socket.close(); + } + } +} + +export default DNSResolver; +// const resolver = new DNSResolver(); +// const domain = process.argv[2] || "google.com"; +// const recordType = process.argv[3] || "A"; +// resolver.getRecord(domain, recordType).then( +// function (records) { +// Object.entries(records).forEach(([type, records]) => { +// if (records.length === 0) return []; // Skip empty records +// return records; +// console.log(">>>>>>---- dns:167 ", records); +// console.log(`\n${type} Records:`); +// records.forEach((record) => { +// console.log("----------------------------------------"); +// console.log(`Type: ${type}`); +// console.log(`Name: ${record.name}`); +// console.log(`TTL: ${record.ttl}`); + +// // Format the output based on record type +// if (type === "MX") { +// console.log(`Priority: ${record.data.priority}`); +// console.log(`Exchange: ${record.data.exchange}`); +// } else { +// console.log(`Data: ${record.data}`); +// // console.log(">>>>>>---- dns-resolver:120 ", record); +// } +// }); +// }); +// }, +// function (err) { +// console.error(err); +// } +// ); diff --git a/src/lib/server/github.js b/src/lib/server/github.js index 89126ecd..bdd7ca94 100644 --- a/src/lib/server/github.js +++ b/src/lib/server/github.js @@ -3,12 +3,18 @@ import axios from "axios"; import { GetMinuteStartNowTimestampUTC } from "./tool.js"; import { marked } from "marked"; import { fileURLToPath } from "url"; +import { siteStore } from "./stores/site.js"; +import { get } from "svelte/store"; import { dirname } from "path"; import dotenv from "dotenv"; + dotenv.config(); +let site = get(siteStore); + const GH_TOKEN = process.env.GH_TOKEN; -const GhnotconfireguredMsg = - "owner or repo or GH_TOKEN is undefined. Read the docs to configure github: https://kener.ing/docs/gh-setup"; + +const GhNotConfiguredMsg = + "Github owner or repo or GH_TOKEN is undefined. Read the docs to configure github: https://kener.ing/docs/gh-setup/"; /** * @param {any} url */ @@ -51,13 +57,13 @@ function patchAxiosOptions(url, data) { return options; } -const GetAllGHLabels = async function (owner, repo) { - if (owner === undefined || repo === undefined || GH_TOKEN === undefined) { - console.log(GhnotconfireguredMsg); +const GetAllGHLabels = async function (site) { + if (!site.hasGithub || GH_TOKEN === undefined) { + console.warn(GhNotConfiguredMsg); return []; } const options = getAxiosOptions( - `https://api.github.com/repos/${owner}/${repo}/labels?per_page=1000` + `${site.github.apiURL}/repos/${site.github.owner}/${site.github.repo}/labels?per_page=1000` ); let labels = []; @@ -73,22 +79,25 @@ const GetAllGHLabels = async function (owner, repo) { function generateRandomColor() { var randomColor = Math.floor(Math.random() * 16777215).toString(16); return randomColor; - //random color will be freshly served } -const CreateGHLabel = async function (owner, repo, label, description, color) { - if (owner === undefined || repo === undefined || GH_TOKEN === undefined) { - console.log(GhnotconfireguredMsg); +const CreateGHLabel = async function (site, label, description, color) { + site = get(siteStore); + if (!site.hasGithub || GH_TOKEN === undefined) { + console.warn(GhNotConfiguredMsg); return null; } if (color === undefined) { color = generateRandomColor(); } - const options = postAxiosOptions(`https://api.github.com/repos/${owner}/${repo}/labels`, { - name: label, - color: color, - description: description - }); + const options = postAxiosOptions( + `${site.github.apiURL}/repos/${site.github.owner}/${site.github.repo}/labels`, + { + name: label, + color: color, + description: description + } + ); try { const response = await axios.request(options); return response.data; @@ -119,16 +128,12 @@ const GetEndTimeFromBody = function (text) { } return null; }; -const GetIncidentByNumber = async function (githubConfig, incidentNumber) { - if ( - githubConfig.owner === undefined || - githubConfig.repo === undefined || - GH_TOKEN === undefined - ) { - console.log(GhnotconfireguredMsg); +const GetIncidentByNumber = async function (incidentNumber) { + if (!site.hasGithub || GH_TOKEN === undefined) { + console.warn(GhNotConfiguredMsg); return null; } - const url = `https://api.github.com/repos/${githubConfig.owner}/${githubConfig.repo}/issues/${incidentNumber}`; + const url = `${site.github.apiURL}/repos/${site.github.owner}/${site.github.repo}/issues/${incidentNumber}`; const options = getAxiosOptions(url); try { const response = await axios.request(options); @@ -138,21 +143,42 @@ const GetIncidentByNumber = async function (githubConfig, incidentNumber) { return null; } }; -const GetIncidents = async function (tagName, githubConfig, state = "all") { - if ( - githubConfig.owner === undefined || - githubConfig.repo === undefined || - GH_TOKEN === undefined - ) { - console.log(GhnotconfireguredMsg); +const GetIncidents = async function (tagName, state = "all") { + if (!site.hasGithub || GH_TOKEN === undefined) { + console.warn(GhNotConfiguredMsg); + return []; + } + if (tagName === undefined) { + return []; + } + const since = GetMinuteStartNowTimestampUTC() - site.github.incidentSince * 60 * 60; + const sinceISO = new Date(since * 1000).toISOString(); + const url = `${site.github.apiURL}/repos/${site.github.owner}/${site.github.repo}/issues?state=${state}&labels=${tagName},incident&sort=created&direction=desc&since=${sinceISO}`; + const options = getAxiosOptions(url); + try { + const response = await axios.request(options); + let issues = response.data; + //issues.createAt should be after sinceISO + issues = issues.filter((issue) => { + return new Date(issue.created_at) >= new Date(sinceISO); + }); + return issues; + } catch (error) { + console.log(error.response?.data); + return []; + } +}; +const GetIncidentsManual = async function (tagName, state = "all") { + if (!site.hasGithub || GH_TOKEN === undefined) { + console.warn(GhNotConfiguredMsg); return []; } if (tagName === undefined) { return []; } - const since = GetMinuteStartNowTimestampUTC() - githubConfig.incidentSince * 60 * 60; + const since = GetMinuteStartNowTimestampUTC() - site.github.incidentSince * 60 * 60; const sinceISO = new Date(since * 1000).toISOString(); - const url = `https://api.github.com/repos/${githubConfig.owner}/${githubConfig.repo}/issues?state=${state}&labels=${tagName},incident&sort=created&direction=desc&since=${sinceISO}`; + const url = `${site.github.apiURL}/repos/${site.github.owner}/${site.github.repo}/issues?state=${state}&labels=${tagName},incident,manual&sort=created&direction=desc&since=${sinceISO}`; const options = getAxiosOptions(url); try { const response = await axios.request(options); @@ -167,19 +193,15 @@ const GetIncidents = async function (tagName, githubConfig, state = "all") { return []; } }; -const GetOpenIncidents = async function (githubConfig) { - if ( - githubConfig.owner === undefined || - githubConfig.repo === undefined || - GH_TOKEN === undefined - ) { - console.log(GhnotconfireguredMsg); +const GetOpenIncidents = async function () { + if (!site.hasGithub || GH_TOKEN === undefined) { + console.warn(GhNotConfiguredMsg); return []; } - const since = GetMinuteStartNowTimestampUTC() - githubConfig.incidentSince * 60 * 60; + const since = GetMinuteStartNowTimestampUTC() - site.github.incidentSince * 60 * 60; const sinceISO = new Date(since * 1000).toISOString(); - const url = `https://api.github.com/repos/${githubConfig.owner}/${githubConfig.repo}/issues?state=open&labels=incident&sort=created&direction=desc&since=${sinceISO}`; + const url = `${site.github.apiURL}/repos/${site.github.owner}/${site.github.repo}/issues?state=open&labels=incident&sort=created&direction=desc&since=${sinceISO}`; const options = getAxiosOptions(url); try { const response = await axios.request(options); @@ -251,16 +273,12 @@ function Mapper(issue) { return res; } -async function GetCommentsForIssue(issueID, githubConfig) { - if ( - githubConfig.owner === undefined || - githubConfig.repo === undefined || - GH_TOKEN === undefined - ) { - console.log(GhnotconfireguredMsg); +async function GetCommentsForIssue(issueID) { + if (!site.hasGithub || GH_TOKEN === undefined) { + console.warn(GhNotConfiguredMsg); return []; } - const url = `https://api.github.com/repos/${githubConfig.owner}/${githubConfig.repo}/issues/${issueID}/comments`; + const url = `${site.github.apiURL}/repos/${site.github.owner}/${site.github.repo}/issues/${issueID}/comments`; try { const response = await axios.request(getAxiosOptions(url)); return response.data; @@ -269,16 +287,12 @@ async function GetCommentsForIssue(issueID, githubConfig) { return []; } } -async function CreateIssue(githubConfig, issueTitle, issueBody, issueLabels) { - if ( - githubConfig.owner === undefined || - githubConfig.repo === undefined || - GH_TOKEN === undefined - ) { - console.log(GhnotconfireguredMsg); +async function CreateIssue(issueTitle, issueBody, issueLabels) { + if (!site.hasGithub || GH_TOKEN === undefined) { + console.warn(GhNotConfiguredMsg); return null; } - const url = `https://api.github.com/repos/${githubConfig.owner}/${githubConfig.repo}/issues`; + const url = `${site.github.apiURL}/repos/${site.github.owner}/${site.github.repo}/issues`; try { const payload = { title: issueTitle, @@ -292,23 +306,12 @@ async function CreateIssue(githubConfig, issueTitle, issueBody, issueLabels) { return null; } } -async function UpdateIssue( - githubConfig, - incidentNumber, - issueTitle, - issueBody, - issueLabels, - state = "open" -) { - if ( - githubConfig.owner === undefined || - githubConfig.repo === undefined || - GH_TOKEN === undefined - ) { - console.log(GhnotconfireguredMsg); +async function UpdateIssue(incidentNumber, issueTitle, issueBody, issueLabels, state = "open") { + if (!site.hasGithub || GH_TOKEN === undefined) { + console.warn(GhNotConfiguredMsg); return null; } - const url = `https://api.github.com/repos/${githubConfig.owner}/${githubConfig.repo}/issues/${incidentNumber}`; + const url = `${site.github.apiURL}/repos/${site.github.owner}/${site.github.repo}/issues/${incidentNumber}`; try { const payload = { title: issueTitle, @@ -323,16 +326,12 @@ async function UpdateIssue( return null; } } -async function CloseIssue(githubConfig, incidentNumber) { - if ( - githubConfig.owner === undefined || - githubConfig.repo === undefined || - GH_TOKEN === undefined - ) { - console.log(GhnotconfireguredMsg); +async function CloseIssue(incidentNumber) { + if (!site.hasGithub || GH_TOKEN === undefined) { + console.warn(GhNotConfiguredMsg); return null; } - const url = `https://api.github.com/repos/${githubConfig.owner}/${githubConfig.repo}/issues/${incidentNumber}`; + const url = `${site.github.apiURL}/repos/${site.github.owner}/${site.github.repo}/issues/${incidentNumber}`; try { const payload = { state: "closed" @@ -344,16 +343,12 @@ async function CloseIssue(githubConfig, incidentNumber) { return null; } } -async function AddComment(githubConfig, incidentNumber, commentBody) { - if ( - githubConfig.owner === undefined || - githubConfig.repo === undefined || - GH_TOKEN === undefined - ) { - console.log(GhnotconfireguredMsg); +async function AddComment(incidentNumber, commentBody) { + if (!site.hasGithub || GH_TOKEN === undefined) { + console.warn(GhNotConfiguredMsg); return null; } - const url = `https://api.github.com/repos/${githubConfig.owner}/${githubConfig.repo}/issues/${incidentNumber}/comments`; + const url = `${site.github.apiURL}/repos/${site.github.owner}/${site.github.repo}/issues/${incidentNumber}/comments`; try { const payload = { body: commentBody @@ -366,16 +361,12 @@ async function AddComment(githubConfig, incidentNumber, commentBody) { } } //update issue labels -async function UpdateIssueLabels(githubConfig, incidentNumber, issueLabels, body, state = "open") { - if ( - githubConfig.owner === undefined || - githubConfig.repo === undefined || - GH_TOKEN === undefined - ) { - console.log(GhnotconfireguredMsg); +async function UpdateIssueLabels(incidentNumber, issueLabels, body, state = "open") { + if (!site.hasGithub || GH_TOKEN === undefined) { + console.warn(GhNotConfiguredMsg); return null; } - const url = `https://api.github.com/repos/${githubConfig.owner}/${githubConfig.repo}/issues/${incidentNumber}`; + const url = `${site.github.apiURL}/repos/${site.github.owner}/${site.github.repo}/issues/${incidentNumber}`; try { const payload = { labels: issueLabels, @@ -393,7 +384,7 @@ async function UpdateIssueLabels(githubConfig, incidentNumber, issueLabels, body //search issue async function SearchIssue(query, page, per_page) { if (GH_TOKEN === undefined) { - console.log(GhnotconfireguredMsg); + console.warn(GhNotConfiguredMsg); return null; } @@ -413,7 +404,7 @@ async function SearchIssue(query, page, per_page) { }) .join(" "); - const url = `https://api.github.com/search/issues?q=${encodeURIComponent( + const url = `${site.github.apiURL}/search/issues?q=${encodeURIComponent( searchQuery )}&per_page=${per_page}&page=${page}`; @@ -442,5 +433,6 @@ export { CloseIssue, GetOpenIncidents, FilterAndInsertMonitorInIncident, - SearchIssue + SearchIssue, + GetIncidentsManual }; diff --git a/src/lib/server/ninety.js b/src/lib/server/ninety.js deleted file mode 100644 index 0ad969f6..00000000 --- a/src/lib/server/ninety.js +++ /dev/null @@ -1,156 +0,0 @@ -// @ts-nocheck -import fs from "fs-extra"; -import { GetMinuteStartNowTimestampUTC, BeginningOfDay } from "./tool.js"; -import { StatusObj, ParseUptime } from "../helpers.js"; - -function getDayMessage(type, numOfMinute) { - if (numOfMinute > 59) { - let hour = Math.floor(numOfMinute / 60); - let minute = numOfMinute % 60; - return `${type} for ${hour}h:${minute}m`; - } else { - return `${type} for ${numOfMinute} minute${numOfMinute > 1 ? "s" : ""}`; - } -} -const NO_DATA = "No Data"; - -function getDayData(day0, startTime, endTime, dayDownMinimumCount, dayDegradedMinimumCount) { - let dayData = { - UP: 0, - DEGRADED: 0, - DOWN: 0, - timestamp: startTime, - cssClass: StatusObj.NO_DATA, - message: NO_DATA - }; - //loop through the ts range - for (let i = startTime; i <= endTime; i += 60) { - //if the ts is in the day0 then add up, down degraded data, if not initialize it - if (day0[i] === undefined) { - continue; - } - - if (day0[i].status == "UP") { - dayData.UP++; - } else if (day0[i].status == "DEGRADED") { - dayData.DEGRADED++; - } else if (day0[i].status == "DOWN") { - dayData.DOWN++; - } - } - - let cssClass = StatusObj.UP; - let message = "Status OK"; - - if (dayData.DEGRADED >= dayDegradedMinimumCount) { - cssClass = StatusObj.DEGRADED; - message = getDayMessage("DEGRADED", dayData.DEGRADED); - } - if (dayData.DOWN >= dayDownMinimumCount) { - cssClass = StatusObj.DOWN; - message = getDayMessage("DOWN", dayData.DOWN); - } - if ( - dayData.DEGRADED + dayData.DOWN + dayData.UP >= - Math.min(dayDownMinimumCount, dayDegradedMinimumCount) - ) { - dayData.message = message; - dayData.cssClass = cssClass; - } - - return dayData; -} - -const Ninety = async (monitor) => { - let _0Day = {}; - let _90Day = {}; - let uptime0Day = "0"; - let dailyUps = 0; - let dailyDown = 0; - let dailyDegraded = 0; - let completeUps = 0; - let completeDown = 0; - let completeDegraded = 0; - - const secondsInDay = 24 * 60 * 60; - const now = GetMinuteStartNowTimestampUTC(); - const midnight = BeginningOfDay({ timeZone: "GMT" }); - const midnight90DaysAgo = midnight - 90 * 24 * 60 * 60; - const midnightTomorrow = midnight + secondsInDay; - - for (let i = midnight; i <= now; i += 60) { - _0Day[i] = { - timestamp: i, - status: "NO_DATA", - cssClass: StatusObj.NO_DATA, - index: (i - midnight) / 60 - }; - } - - let day0 = JSON.parse(fs.readFileSync(monitor.path0Day, "utf8")); - - for (const timestamp in day0) { - const element = day0[timestamp]; - let status = element.status; - if (status == "UP") { - completeUps++; - } else if (status == "DEGRADED") { - completeDegraded++; - } else if (status == "DOWN") { - completeDown++; - } - //0 Day data - if (_0Day[timestamp] !== undefined) { - _0Day[timestamp].status = status; - _0Day[timestamp].cssClass = StatusObj[status]; - - dailyUps = status == "UP" ? dailyUps + 1 : dailyUps; - dailyDown = status == "DOWN" ? dailyDown + 1 : dailyDown; - dailyDegraded = status == "DEGRADED" ? dailyDegraded + 1 : dailyDegraded; - } - } - - for (let i = midnight90DaysAgo; i < midnightTomorrow; i += secondsInDay) { - _90Day[i] = getDayData( - day0, - i, - i + secondsInDay - 1, - monitor.dayDownMinimumCount, - monitor.dayDegradedMinimumCount - ); - } - - for (const key in _90Day) { - const element = _90Day[key]; - delete _90Day[key].UP; - delete _90Day[key].DEGRADED; - delete _90Day[key].DOWN; - if (element.message == NO_DATA) continue; - } - - let uptime0DayNumerator = dailyUps + dailyDegraded; - let uptime0DayDenominator = dailyUps + dailyDown + dailyDegraded; - let uptime90DayNumerator = completeUps + completeDegraded; - let uptime90DayDenominator = completeUps + completeDown + completeDegraded; - - if (monitor.includeDegradedInDowntime === true) { - uptime0DayNumerator = dailyUps; - uptime90DayNumerator = completeUps; - } - uptime0Day = ParseUptime(uptime0DayNumerator, uptime0DayDenominator); - - const dataToWrite = { - _90Day: _90Day, - uptime0Day, - uptime90Day: ParseUptime(uptime90DayNumerator, uptime90DayDenominator), - dailyUps, - dailyDown, - dailyDegraded - }; - - await fs.writeJson(monitor.path90Day, dataToWrite); - - return true; -}; - -export { Ninety }; diff --git a/src/lib/server/notification/discord.js b/src/lib/server/notification/discord.js new file mode 100644 index 00000000..0673d472 --- /dev/null +++ b/src/lib/server/notification/discord.js @@ -0,0 +1,94 @@ +// @ts-nocheck +class Discord { + url; + headers; + method; + siteData; + monitorData; + envSecrets; + + constructor(url, siteData, monitorData, envSecrets) { + 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.envSecrets = envSecrets; + } + + transformData(data) { + let siteURL = this.siteData.siteURL; + let logo = this.siteData.logo.startsWith("/") + ? siteURL + this.siteData.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; diff --git a/src/lib/server/notification/notif.js b/src/lib/server/notification/notif.js new file mode 100644 index 00000000..92bda3b3 --- /dev/null +++ b/src/lib/server/notification/notif.js @@ -0,0 +1,32 @@ +// @ts-nocheck +import Webhook from "./webhook.js"; +import Discord from "./discord.js"; +import Slack from "./slack.js"; + +class Notification { + client; + + constructor(config, siteData, monitorData) { + if (config.type === "webhook") { + this.client = new Webhook( + config.url, + config.headers, + config.method, + siteData, + monitorData + ); + } else if (config.type === "discord") { + this.client = new Discord(config.url, siteData, monitorData); + } else if (config.type === "slack") { + this.client = new Slack(config.url, siteData, monitorData); + } else { + console.log("Invalid Notification"); + process.exit(1); + } + } + + async send(data) { + return await this.client.send(data); + } +} +export default Notification; diff --git a/src/lib/server/notification/slack.js b/src/lib/server/notification/slack.js new file mode 100644 index 00000000..3714224d --- /dev/null +++ b/src/lib/server/notification/slack.js @@ -0,0 +1,100 @@ +// @ts-nocheck +class Slack { + url; + headers; + method; + siteData; + monitorData; + + 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; + } + + transformData(alert) { + return { + blocks: [ + { + type: "header", + text: { + type: "plain_text", + text: alert.alert_name, + emoji: true + } + }, + { + type: "header", + text: { + type: "plain_text", + text: alert.status === "TRIGGERED" ? "πŸ”΄ Triggered" : "🟒 Resolved", + emoji: true + } + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `${alert.description}\n*Source:* ${alert.source}\n*Severity:* ${alert.severity}\n*Status:* ${alert.status}` + } + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: `*Metric:*\n${alert.details.metric}` + }, + { + type: "mrkdwn", + text: `*Current Value:*\n${alert.details.current_value}` + }, + { + type: "mrkdwn", + text: `*Threshold:*\n${alert.details.threshold}` + }, + { + type: "mrkdwn", + text: `*Timestamp:*\n` + } + ] + }, + { + type: "actions", + elements: alert.actions.map((action) => ({ + type: "button", + text: { + type: "plain_text", + text: action.text + }, + url: action.url, + style: "primary" + })) + } + ] + }; + } + + 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 Slack; diff --git a/src/lib/server/notification/webhook.js b/src/lib/server/notification/webhook.js new file mode 100644 index 00000000..d3ff1605 --- /dev/null +++ b/src/lib/server/notification/webhook.js @@ -0,0 +1,58 @@ +// @ts-nocheck + +import { GetRequiredSecrets, ReplaceAllOccurrences } from "../tool.js"; + +class Webhook { + url; + headers; + method; + siteData; + monitorData; + + constructor(url, headers, method, siteData, monitorData) { + const kenerHeader = { + "Content-Type": "application/json", + "User-Agent": "Kener/2.0.0" + }; + + this.url = url; + this.headers = Object.assign(kenerHeader, headers); + this.method = method || "POST"; + this.siteData = siteData; + this.monitorData = monitorData; + + let envSecrets = GetRequiredSecrets(`${this.url} ${JSON.stringify(this.headers)}`); + + for (let i = 0; i < envSecrets.length; i++) { + const secret = envSecrets[i]; + this.url = ReplaceAllOccurrences(this.url, secret.key, secret.value); + this.headers = JSON.parse( + ReplaceAllOccurrences(JSON.stringify(this.headers), secret.find, secret.replace) + ); + } + } + + transformData(data) { + return data; + } + + type() { + return "webhook"; + } + + 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 Webhook; diff --git a/src/lib/server/page.js b/src/lib/server/page.js index 2699b81b..b545de99 100644 --- a/src/lib/server/page.js +++ b/src/lib/server/page.js @@ -1,8 +1,189 @@ // @ts-nocheck // @ts-ignore -import fs from "fs-extra"; +import db from "$lib/server/db/db.js"; +import { + GetMinuteStartNowTimestampUTC, + BeginningOfDay, + StatusObj, + StatusColor, + ParseUptime, + GetDayStartTimestampUTC +} from "$lib/server/tool.js"; +import { siteStore } from "$lib/server/stores/site"; +import { get } from "svelte/store"; -const FetchData = async function (monitor) { - return fs.readJsonSync(monitor.path90Day); +function getDayMessage(type, numOfMinute) { + if (numOfMinute > 59) { + let hour = Math.floor(numOfMinute / 60); + let minute = numOfMinute % 60; + return `${type} for ${hour}h:${minute}m`; + } else { + return `${type} for ${numOfMinute} minute${numOfMinute > 1 ? "s" : ""}`; + } +} +function getTimezoneOffset(timeZone) { + const formatter = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "short" }); + const parts = formatter.formatToParts(new Date()); + const timeZoneOffset = parts.find((part) => part.type === "timeZoneName")?.value; + + const match = timeZoneOffset.match(/([+-]\d{2}):?(\d{2})/); + if (!match) return 0; + + const [, hours, minutes] = match; + return parseInt(hours) * 60 + parseInt(minutes); +} + +function returnStatusClass(val, c, barStyle) { + if (barStyle === undefined || barStyle == "FULL") { + return c; + } else if (barStyle == "PARTIAL") { + let totalHeight = 24 * 60; + let cl = `api-up`; + if (val > 0 && val <= 0.1 * totalHeight) { + cl = c + "-10"; + } else if (val > 0.1 * totalHeight && val <= 0.2 * totalHeight) { + cl = c + "-20"; + } else if (val > 0.2 * totalHeight && val <= 0.4 * totalHeight) { + cl = c + "-40"; + } else if (val > 0.4 * totalHeight && val <= 0.6 * totalHeight) { + cl = c + "-60"; + } else if (val > 0.6 * totalHeight && val <= 0.8 * totalHeight) { + cl = c + "-80"; + } else if (val > 0.8 * totalHeight && val < totalHeight) { + cl = c + "-90"; + } else if (val == totalHeight) { + cl = c; + } + return cl; + } + return c; +} + +function getCountOfSimilarStatuesEnd(arr, statusType) { + let count = 0; + for (let i = arr.length - 1; i >= 0; i--) { + if (arr[i].status === statusType) { + count++; + } else { + break; + } + } + return count; +} + +const FetchData = async function (monitor, localTz) { + const secondsInDay = 24 * 60 * 60; + + let site = get(siteStore); + //get offset from utc in minutes + + const now = GetMinuteStartNowTimestampUTC() + 60; + const midnight = BeginningOfDay({ timeZone: localTz }); + const midnight90DaysAgo = midnight - 90 * 24 * 60 * 60; + const NO_DATA = "No Data"; + const midnightTomorrow = midnight + secondsInDay; + let offsetInMinutes = parseInt((GetDayStartTimestampUTC(now) - midnight) / 60); + const _90Day = {}; + let latestTimestamp = 0; + let ij = 0; + for (let i = midnight90DaysAgo; i < midnightTomorrow; i += secondsInDay) { + _90Day[i] = { + UP: 0, + DEGRADED: 0, + DOWN: 0, + timestamp: i, + cssClass: StatusObj.NO_DATA, + textClass: StatusObj.NO_DATA, + message: NO_DATA, + border: true, + ij: ij + }; + ij++; + latestTimestamp = i; + } + + let dbData = await db.getDataGroupByDayAlternative( + monitor.tag, + midnight90DaysAgo, + midnightTomorrow, + offsetInMinutes + ); + let totalDegradedCount = 0; + let totalDownCount = 0; + let totalUpCount = 0; + + let summaryText = NO_DATA; + let summaryColorClass = "api-nodata"; + + for (let i = 0; i < dbData.length; i++) { + let dayData = dbData[i]; + let ts = dayData.timestamp; + let cssClass = StatusObj.UP; + let message = "Status OK"; + + totalDegradedCount += dayData.DEGRADED; + totalDownCount += dayData.DOWN; + totalUpCount += dayData.UP; + + if (dayData.DEGRADED >= monitor.dayDegradedMinimumCount) { + cssClass = returnStatusClass(dayData.DEGRADED, StatusObj.DEGRADED, site.barStyle); + message = getDayMessage("DEGRADED", dayData.DEGRADED); + } + if (dayData.DOWN >= monitor.dayDownMinimumCount) { + cssClass = returnStatusClass(dayData.DOWN, StatusObj.DOWN, site.barStyle); + message = getDayMessage("DOWN", dayData.DOWN); + } + + if (!!_90Day[ts]) { + _90Day[ts].timestamp = ts; + _90Day[ts].cssClass = cssClass; + _90Day[ts].message = message; + _90Day[ts].textClass = cssClass.replace(/-\d+$/, ""); + } + } + let uptime90DayNumerator = totalUpCount + totalDegradedCount; + let uptime90DayDenominator = totalUpCount + totalDownCount + totalDegradedCount; + + //remove degraded from uptime + if (monitor.includeDegradedInDowntime === true) { + uptime90DayNumerator = totalUpCount; + } + // return _90Day; + let uptime90Day = ParseUptime(uptime90DayNumerator, uptime90DayDenominator); + if (site.summaryStyle === "CURRENT") { + let todayDataDb = await db.getData( + monitor.tag, + latestTimestamp, + latestTimestamp + secondsInDay + ); + + summaryText = "Status OK"; + summaryColorClass = "api-up"; + + let lastRow = todayDataDb[todayDataDb.length - 1]; + + if (!!lastRow && lastRow.status == "DEGRADED") { + summaryText = getDayMessage( + "DEGRADED", + getCountOfSimilarStatuesEnd(todayDataDb, "DEGRADED") + ); + summaryColorClass = "api-degraded"; + } + if (!!lastRow && lastRow.status == "DOWN") { + summaryText = getDayMessage("DOWN", getCountOfSimilarStatuesEnd(todayDataDb, "DOWN")); + summaryColorClass = "api-down"; + } + } else { + let lastData = _90Day[latestTimestamp]; + summaryText = lastData.message; + summaryColorClass = lastData.cssClass.replace(/-\d+$/, ""); + } + return { + _90Day: _90Day, + uptime90Day: uptime90Day, + summaryText: summaryText, + summaryColorClass: summaryColorClass, + barRoundness: site.barRoundness + }; }; export { FetchData }; diff --git a/src/lib/server/startup.js b/src/lib/server/startup.js index 6e3c3840..a75a0cc3 100644 --- a/src/lib/server/startup.js +++ b/src/lib/server/startup.js @@ -9,41 +9,32 @@ name of each of these objects need to be unique import * as dotenv from "dotenv"; import fs from "fs-extra"; import path from "path"; +import figlet from "figlet"; import yaml from "js-yaml"; import { Cron } from "croner"; import { API_TIMEOUT } from "./constants.js"; -import { IsValidURL, IsValidHTTPMethod, ValidateIpAddress } from "./tool.js"; -import { GetAllGHLabels, CreateGHLabel } from "./github.js"; +import { IsValidURL, IsValidHTTPMethod, ValidateIpAddress, ValidateMonitorAlerts } from "./tool.js"; + import { Minuter } from "./cron-minute.js"; import axios from "axios"; -import { Ninety } from "./ninety.js"; -let monitors = []; -let site = {}; -const envSecrets = []; +import db from "./db/db.js"; +import Queue from "queue"; + const DATABASE_PATH = "./database"; const Startup = async () => { - const FOLDER_MONITOR_JSON = DATABASE_PATH + "/monitors.json"; - const monitors = fs.readJSONSync(FOLDER_MONITOR_JSON); + const monitors = fs.readJSONSync(DATABASE_PATH + "/monitors.json"); const site = fs.readJSONSync(DATABASE_PATH + "/site.json"); + const startUPLog = {}; // init monitors for (let i = 0; i < monitors.length; i++) { const monitor = monitors[i]; - if (!fs.existsSync(monitor.path0Day)) { - fs.ensureFileSync(monitor.path0Day); - fs.writeFileSync(monitor.path0Day, JSON.stringify({})); - } - if (!fs.existsSync(monitor.path90Day)) { - fs.ensureFileSync(monitor.path90Day); - fs.writeFileSync(monitor.path90Day, JSON.stringify({})); - } - - console.log("Initial Fetch for ", monitor.name); - await Minuter(envSecrets, monitor, site.github); - await Ninety(monitor); + await Minuter(monitor, site.github, site); + startUPLog[monitor.name] = { + "Initial Fetch": "βœ…" + }; } - //trigger minute cron for (let i = 0; i < monitors.length; i++) { const monitor = monitors[i]; @@ -52,26 +43,39 @@ const Startup = async () => { if (monitor.cron !== undefined && monitor.cron !== null) { cronExpression = monitor.cron; } - console.log("Staring " + cronExpression + " Cron for ", monitor.name); Cron(cronExpression, async () => { - await Minuter(envSecrets, monitor, site.github); + await Minuter(monitor, site.github, site); }); + startUPLog[monitor.name]["Monitoring At"] = cronExpression; + if (monitor.alerts && ValidateMonitorAlerts(monitor.alerts)) { + startUPLog[monitor.name]["Alerting"] = "βœ…"; + } else { + startUPLog[monitor.name]["Alerting"] = "❗"; + } } + const tableData = Object.entries(startUPLog).map(([name, details]) => ({ + Monitor: name, + ...details + })); - //pre compute 90 day data at 1 minute interval + console.table(tableData); Cron( "* * * * *", async () => { - for (let i = 0; i < monitors.length; i++) { - const monitor = monitors[i]; - Ninety(monitor); - } + await db.background(); }, { protect: true } ); + figlet("Kener is UP!", function (err, data) { + if (err) { + console.log("Something went wrong..."); + return; + } + console.log(data); + }); return 1; }; diff --git a/src/lib/server/stores/server.js b/src/lib/server/stores/server.js new file mode 100644 index 00000000..5fb82d82 --- /dev/null +++ b/src/lib/server/stores/server.js @@ -0,0 +1,15 @@ +// @ts-nocheck +import { readable } from "svelte/store"; +import fs from "fs-extra"; +import path from "path"; + +// Load the JSON data from the file system +let serverData = {}; +try { + const serverDataPath = path.join(process.cwd(), "database", "server.json"); + serverData = fs.readJSONSync(serverDataPath, "utf8"); +} catch (error) { + console.error("Error loading site data", error); +} +// Create a readonly store +export const serverStore = readable(serverData, () => {}); diff --git a/src/lib/server/stores/site.js b/src/lib/server/stores/site.js index ea002af9..7badc408 100644 --- a/src/lib/server/stores/site.js +++ b/src/lib/server/stores/site.js @@ -1,3 +1,4 @@ +// @ts-nocheck import { readable } from "svelte/store"; import fs from "fs-extra"; import path from "path"; @@ -7,6 +8,8 @@ let siteData = {}; try { const siteDataPath = path.join(process.cwd(), "database", "site.json"); siteData = fs.readJSONSync(siteDataPath, "utf8"); -} catch (error) {} +} catch (error) { + console.error("Error loading site data", error); +} // Create a readonly store export const siteStore = readable(siteData, () => {}); diff --git a/src/lib/server/tool.js b/src/lib/server/tool.js index 72653a78..cdba415a 100644 --- a/src/lib/server/tool.js +++ b/src/lib/server/tool.js @@ -1,4 +1,8 @@ // @ts-nocheck +import { AllRecordTypes } from "./constants.js"; +import { siteStore } from "./stores/site.js"; +import { get } from "svelte/store"; +const site = get(siteStore); import dotenv from "dotenv"; dotenv.config(); const IsValidURL = function (url) { @@ -136,11 +140,152 @@ const ValidateIpAddress = function (input) { function checkIfDuplicateExists(arr) { return new Set(arr).size !== arr.length; } -function getWordsStartingWithDollar(text) { +function GetWordsStartingWithDollar(text) { 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" +}; +const StatusColor = { + UP: site.colors?.UP || "#00dfa2", + DEGRADED: site.colors?.DEGRADED || "#e6ca61", + DOWN: site.colors?.DOWN || "#ca3038", + NO_DATA: "#f1f5f8" +}; +// @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)); +}; +const ParsePercentage = function (n) { + 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); +} + +//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); +} + +//valid dns record type +function IsValidRecordType(recordType) { + return AllRecordTypes.hasOwnProperty(recordType); +} +function ReplaceAllOccurrences(originalString, searchString, replacement) { + 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; +} + +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; +} + export { IsValidURL, IsValidHTTPMethod, @@ -154,5 +299,15 @@ export { IsStringURLSafe, ValidateIpAddress, checkIfDuplicateExists, - getWordsStartingWithDollar + GetWordsStartingWithDollar, + StatusObj, + StatusColor, + ParseUptime, + ParsePercentage, + IsValidHost, + IsValidRecordType, + IsValidNameServer, + ReplaceAllOccurrences, + GetRequiredSecrets, + ValidateMonitorAlerts }; diff --git a/src/lib/server/webhook.js b/src/lib/server/webhook.js index 7edf81ee..bbf63851 100644 --- a/src/lib/server/webhook.js +++ b/src/lib/server/webhook.js @@ -1,19 +1,18 @@ // @ts-nocheck -import fs from "fs-extra"; -import { monitorsStore } from "./stores/monitors"; +import { monitorsStore } from "./stores/monitors.js"; import { get } from "svelte/store"; -import { ParseUptime } from "$lib/helpers.js"; import { GetMinuteStartNowTimestampUTC, GetNowTimestampUTC, - GetMinuteStartTimestampUTC + GetMinuteStartTimestampUTC, + ParseUptime, + GetDayStartTimestampUTC } from "./tool.js"; import { GetStartTimeFromBody, GetEndTimeFromBody } from "./github.js"; -import Randomstring from "randomstring"; const API_TOKEN = process.env.API_TOKEN; const API_IP = process.env.API_IP; const API_IP_REGEX = process.env.API_IP_REGEX; -import path from "path"; +import db from "./db/db.js"; const GetAllTags = function () { let tags = []; @@ -74,7 +73,7 @@ const auth = function (request) { } return null; }; -const store = function (data) { +const store = async function (data) { const tag = data.tag; //remove Bearer from start in authHeader @@ -117,24 +116,20 @@ const store = function (data) { let monitors = get(monitorsStore); const monitor = monitors.find((monitor) => monitor.tag === tag); - //read the monitor.path0Day file - let day0 = {}; - - day0[data.timestampInSeconds] = resp; - //sort the keys - - //create a random string with high cardinlity - //to avoid cache - const Kener_folder = "./database"; - //write the monitor.path0Day file - fs.writeFileSync( - Kener_folder + `/${monitor.folderName}.webhook.${Randomstring.generate()}.json`, - JSON.stringify(day0, null, 2) - ); - + await db.insertData({ + monitorTag: tag, + timestamp: data.timestampInSeconds, + status: resp.status, + latency: resp.latency, + type: "webhook" + }); return { status: 200, message: "success at " + data.timestampInSeconds }; }; const GHIssueToKenerIncident = function (issue) { + if (!!!issue) { + return null; + } + let issueLabels = issue.labels.map((label) => { return label.name; }); @@ -252,7 +247,7 @@ const ParseIncidentPayload = function (payload) { return { title, body, githubLabels }; }; -const GetMonitorStatusByTag = function (tag) { +const GetMonitorStatusByTag = function (tag, timestamp) { if (!CheckIfValidTag(tag)) { return { error: "invalid tag", status: 400 }; } @@ -262,29 +257,38 @@ const GetMonitorStatusByTag = function (tag) { lastUpdatedAt: null }; let monitors = get(monitorsStore); - const { path0Day } = monitors.find((monitor) => monitor.tag === tag); - const dayData = JSON.parse(fs.readFileSync(path0Day, "utf8")); - const lastUpdatedAt = Object.keys(dayData)[Object.keys(dayData).length - 1]; - const lastObj = dayData[lastUpdatedAt]; - resp.status = lastObj.status; - //add all status up, degraded, down + const { includeDegradedInDowntime } = monitors.find((monitor) => monitor.tag === tag); + + let now = GetMinuteStartNowTimestampUTC(); + if (timestamp !== null && timestamp !== undefined) { + now = timestamp; + } + let start = GetDayStartTimestampUTC(now); + + let dayDataNew = db.getData(tag, start, now); let ups = 0; let downs = 0; let degradeds = 0; + let lastData = dayDataNew[dayDataNew.length - 1]; - for (const timestamp in dayData) { - const obj = dayData[timestamp]; - if (obj.status == "UP") { + for (let i = 0; i < dayDataNew.length; i++) { + let row = dayDataNew[i]; + if (row.status == "UP") { ups++; - } else if (obj.status == "DEGRADED") { + } else if (row.status == "DEGRADED") { degradeds++; - } else if (obj.status == "DOWN") { + } else if (row.status == "DOWN") { downs++; } } + let numerator = ups + degradeds; + if (includeDegradedInDowntime === true) { + numerator = ups; + } - resp.uptime = ParseUptime(ups + degradeds, ups + degradeds + downs); - resp.lastUpdatedAt = Number(lastUpdatedAt); + resp.uptime = ParseUptime(numerator, ups + degradeds + downs); + resp.lastUpdatedAt = Number(lastData.timestamp); + resp.status = lastData.status; return { status: 200, ...resp }; }; export { diff --git a/src/routes/(docs)/+layout.svelte b/src/routes/(docs)/+layout.svelte index 70965c02..883115f9 100644 --- a/src/routes/(docs)/+layout.svelte +++ b/src/routes/(docs)/+layout.svelte @@ -66,7 +66,7 @@
-
+