Compare commits

..

58 Commits

Author SHA1 Message Date
Raj Nandan Sharma 883c46b064 Merge pull request #214 from rajnandan1/release/3.0.2
release of 3.0.2
2025-01-24 10:48:15 +05:30
Raj Nandan Sharma 4248cc31f2 chore: updated package.json version 2025-01-24 10:47:56 +05:30
Raj Nandan Sharma 5f81c6d61a fix: daily data local date format 2025-01-24 10:45:50 +05:30
Raj Nandan Sharma 00d82cc817 fix: daily data local date format 2025-01-24 10:44:48 +05:30
Raj Nandan Sharma a9edfde162 fix: bug fix for no data 2025-01-24 10:35:22 +05:30
Raj Nandan Sharma f9831490af fix: base path for docker build 2025-01-24 09:08:13 +05:30
Raj Nandan Sharma a055a616eb fix: fix logo url as reported in #213 2025-01-23 23:37:39 +05:30
Raj Nandan Sharma 8009a2cbc4 feat: monitor re-arrange as requested in #215 2025-01-23 22:39:13 +05:30
Raj Nandan Sharma 693cc149cf feat: added retry for api timeouts as requested in #208 2025-01-23 10:33:15 +05:30
Raj Nandan Sharma d126fe0bce fix: fix for #212, data interpolation introduced 2025-01-23 09:36:37 +05:30
Raj Nandan Sharma 4cc49cce9e fix: default locale fix as reported in #209 2025-01-22 09:25:56 +05:30
Raj Nandan Sharma f7cb4cd805 fix: cron validation updated as reported in #206 2025-01-21 22:02:39 +05:30
Raj Nandan Sharma a2bd9883d1 chore: remove old docs.md 2025-01-21 09:18:21 +05:30
Raj Nandan Sharma 10a079c304 fix: fixing #193 base path login was having double slash in cookie 2025-01-21 09:12:10 +05:30
Raj Nandan Sharma 65f12f1082 docs: added contributing file 2025-01-21 08:44:58 +05:30
Raj Nandan Sharma c15f855c72 docs: added contributing file 2025-01-21 08:42:41 +05:30
Raj Nandan Sharma 3d38483022 Merge pull request #205 from matribeiro15/main
Add Brazilian Portuguese
2025-01-21 08:31:33 +05:30
Mateus Ribeiro 57dc1e7175 modified: src/lib/i18n/client.js
modified:   src/lib/locales/locales.json
	new file:   src/lib/locales/pt-BR.json
2025-01-21 01:22:01 +00:00
Raj Nandan Sharma ead68c929f docs: embed docs 2025-01-18 23:19:38 +05:30
Raj Nandan Sharma d54b346259 Merge pull request #204 from rajnandan1/date-fns
feat: i18n for dates
2025-01-18 23:14:13 +05:30
Raj Nandan Sharma a05de8269c docs: updated feature list 2025-01-18 23:13:13 +05:30
Raj Nandan Sharma 130b70c6af feat: i18n for dates 2025-01-18 23:10:07 +05:30
Raj Nandan Sharma 7247a54c08 feat: i18n for dates 2025-01-18 22:46:15 +05:30
Raj Nandan Sharma 28de7ebff8 feat: i18n for dates 2025-01-18 22:07:54 +05:30
Raj Nandan Sharma e2c9d4dc2d feat: i18n for dates 2025-01-18 21:44:46 +05:30
Raj Nandan Sharma 4866382180 feat: i18n for dates 2025-01-18 21:36:51 +05:30
Raj Nandan Sharma 1b1507db1a Merge pull request #202 from rajnandan1/heroku-dep
fix: fixes #198
2025-01-18 12:40:12 +05:30
Raj Nandan Sharma 0ea7d687a7 fix: fixes #198 2025-01-18 12:39:05 +05:30
Raj Nandan Sharma 502e1539ca Merge pull request #201 from sandyi5/main
korean translation update for v 3.0
2025-01-18 10:31:24 +05:30
김세영 f3910fea1b Update ko.json 2025-01-18 12:44:33 +09:00
김세영 ee9b48fb79 Create ko.json 2025-01-18 12:43:39 +09:00
김세영 142686ef92 Update locales.json 2025-01-18 12:24:57 +09:00
Raj Nandan Sharma 2aa187984e fix: cookie secure fix 2025-01-17 21:26:55 +05:30
Raj Nandan Sharma c20d4d71c3 fix: cookie secure fix 2025-01-17 20:37:02 +05:30
Raj Nandan Sharma 4f4d1e1b39 fix: login redirection to setup if not done 2025-01-17 09:51:07 +05:30
Raj Nandan Sharma f38b590f70 fix: handle file uplaod for docker container 2025-01-17 08:48:21 +05:30
Raj Nandan Sharma c2caf42fd3 Merge pull request #195 from rajnandan1/fix/day0
Fix/day0
2025-01-17 08:44:52 +05:30
Raj Nandan Sharma 8c279c8c10 fix: fixed bugs reported in reddit around analytics and css 2025-01-17 08:43:35 +05:30
Raj Nandan Sharma 3480c78360 fix: fixed bugs reported in reddit around analytics and css 2025-01-17 08:39:29 +05:30
Raj Nandan Sharma 79ce708f60 fixes reported on reddit 2025-01-16 23:13:47 +05:30
Raj Nandan Sharma a4526eb4b5 fix: nav list in home manage 2025-01-15 22:25:23 +05:30
Raj Nandan Sharma 866a133917 Merge pull request #194 from jghaanstra/main 2025-01-15 22:16:30 +05:30
Jelger Haanstra adce978244 Add Dutch Translation 2025-01-15 17:42:37 +01:00
Raj Nandan Sharma dd3c26b29a fix: fixing #193 and #191 2025-01-15 22:02:15 +05:30
Raj Nandan Sharma 25ad42ba9b Merge pull request #190 from otherwiseGG/danish-customer-language
Danish Translation
2025-01-15 21:29:56 +05:30
Benjamin Thiele 3285b7e472 Merge branch 'main' into danish-customer-language 2025-01-15 16:43:28 +01:00
Raj Nandan Sharma c7da55480a docs(docker): updated roadmap 2025-01-15 09:35:14 +05:30
Raj Nandan Sharma 35e81b5782 fix(docker): fixing #188 and #189
CHANGE: startup script started running before migrations of database, moved it to after migration
2025-01-15 09:24:05 +05:30
Raj Nandan Sharma 769a9526e7 Merge pull request #187 from kutovoys/main
Add Russian translations and update locales.json
2025-01-15 08:59:43 +05:30
Benjamin Thiele 5470153f4f Merge branch 'main' into danish-customer-language 2025-01-15 00:15:52 +01:00
Benjamin Thiele 9d6ad87275 Create dk.json 2025-01-15 00:13:55 +01:00
Benjamin Thiele ac6261d9ac Update locales.json 2025-01-15 00:12:18 +01:00
Sergey Kutovoy 48effe077e Add Russian translations and update locales.json
- Added Russian language support by creating ru.json with translations for various terms and phrases.
- Updated locales.json to include Russian in the list of available languages.
2025-01-15 00:48:57 +05:00
Raj Nandan Sharma c656bf8612 Merge pull request #186 from otherwiseGG/patch-1 2025-01-14 23:43:42 +05:30
Benjamin Thiele 9daf7c7156 Update locales.json 2025-01-14 18:53:27 +01:00
Benjamin Thiele 4bd3bfae9d Create de.json
German Translation File
2025-01-14 18:51:38 +01:00
Raj Nandan Sharma 727964949c docs: updated i18n docs 2025-01-14 23:10:24 +05:30
Raj Nandan Sharma 21e1fc1aca fix: fix seo meta tags 2025-01-14 22:49:00 +05:30
75 changed files with 1467 additions and 1648 deletions
+1 -1
View File
@@ -1,11 +1,11 @@
KENER_SECRET_KEY=please_change_me
NODE_ENV=production
PORT=3000
GH_TOKEN=your_github_token
KENER_BASE_PATH=""
RESEND_API_KEY=
ORIGIN=http://localhost:3000
DATABASE_URL=sqlite://./database/kener.sqlite.db
TZ=UTC
RESEND_SENDER_EMAIL=Accounts <accounts@resend.dev>
# DATABASE_URL=postgresql://myuser:mypassword@localhost:5432/mydatabase
+48
View File
@@ -0,0 +1,48 @@
# Contributing to Kener
Thank you for considering contributing to our project! Here are some guidelines to help you get started.
---
## How to Contribute
1. Fork the repository and clone it locally.
2. Create a new branch for your feature or bug fix:
```bash
git checkout -b feature/your-feature-name
```
3. Make your changes and commit them:
```bash
git commit -m 'Describe your changes'
```
4. Push your changes to your fork:
```bash
git push origin feature/your-feature-name
```
5. Create a pull request to the `main` branch.
## Development
1. Install dependencies:
```bash
npm install
```
2. Create a `.env` file in the root of the project and add the following:
```bash
cp .env.example .env
```
2. Start the development server:
```bash
npm run dev
```
3. Open [http://localhost:3000](http://localhost:3000) in your browser.
## Documentation
The documentation is available in the `docs` folder. You can view it by going to [http://localhost:3000/docs/home](http://localhost:3000/docs/home) in your browser.
## Where to Start
1. Check out the [roadmap items](https://kener.ing/docs/roadmap/)
2. Add language support by following the [i18n guide](https://kener.ing/docs/i18n/)
+2
View File
@@ -21,6 +21,8 @@ db/*
!db/.kener
database/*
!database/.kener
uploads/*
!uploads/upload.dir
static/uploads/*
!static/uploads/upload.dir
+16 -2
View File
@@ -11,11 +11,14 @@ RUN apt-get update && apt-get install -y \
make \
node-gyp \
g++ \
tzdata && \
tzdata \
iputils-ping && \
rm -rf /var/lib/apt/lists/*
# Set the timezone environment variable
# Set the timezone environment variable and the application environment
ARG KENER_BASE_PATH=
ENV TZ=Etc/UTC
ENV KENER_BASE_PATH=${KENER_BASE_PATH}
# Set the working directory
WORKDIR /app
@@ -27,9 +30,20 @@ RUN npm install && npm cache clean --force
# Copy the rest of the application code
COPY . .
# Ensure /app/uploads and /app/database have rw permissions
RUN mkdir -p /app/uploads /app/database && \
chmod -R 777 /app/uploads /app/database
# Build the application
RUN npm run build
# Argument for the port
ARG PORT=3000
# Set the environment variable for the port
ENV PORT=$PORT
# Expose the application port
EXPOSE $PORT
# Set the command to run the application
CMD ["node", "main"]
+13 -19
View File
@@ -54,14 +54,14 @@ Here are some of the features that you get out of the box. Please read the docum
- Advanced application performance monitoring tools
- Real-time network monitor software capabilities
- Polls HTTP endpoint or Push data to monitor using Rest APIs
- Handles Timezones for visitors
- Adjusts Timezones for visitors
- Categorize Monitors into different Sections
- Cron-based scheduling for monitors. Minimum per minute
- Flexible monitor configuration using YAML
- Construct complex API Polls - Chain, Secrets etc
- Supports a Default Status for Monitors
- Supports base path for hosting in k8s
- Pre-built docker image for easy deployment
- Automatically adjusts timezones for visitors
### Customization and Branding
@@ -77,36 +77,22 @@ Here are some of the features that you get out of the box. Please read the docum
- Incident Management
- Incident Communication
- Comprehensive APIs for Incident Management
### User Experience and Design
- 100% Accessibility Score
- Good Accessibility Score
- Easy installation and setup
- User-friendly interface
- Responsive design for various devices
- Auto SEO and Social Media ready
- Server Side Rendering
## Technologies used
- [SvelteKit](https://kit.svelte.dev/)
- [shadcn-svelte](https://www.shadcn-svelte.com/)
## Inspired from
- [Upptime](https://upptime.js.org/)
## Screenshots
![image](static/marken_90.png)
![image](static/marken_api.png)
![image](static/marken_badge.png)
![image](static/marken_embed.png)
![image](static/marken_inci.png)
![image](static/marken_share.png)
![image](static/marken_td.png)
![image](static/marken_tl.png)
![image](static/marken_theme.png)
## Support Me
If you are using Kener and want to support me, you can do so by sponsoring me on GitHub or buying me a coffee.
@@ -116,3 +102,11 @@ If you are using Kener and want to support me, you can do so by sponsoring me on
[Buy Me a Coffee](https://www.buymeacoffee.com/rajnandan1)
![image](https://badges.pufler.dev/visits/rajnandan1/kener)
## Contributing
If you want to contribute to Kener, please read the [Contributing Guide](https://github.com/rajnandan1/kener/blob/main/.github/CONTRIBUTING.md).
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=rajnandan1/kener&type=Date)](https://star-history.com/#rajnandan1/kener&Date)
+3 -5
View File
@@ -7,17 +7,14 @@ services:
environment:
- TZ=Etc/UTC
- NODE_ENV=production
#- GH_TOKEN=
#- PORT=3000
#- KENER_BASE_PATH=
#- RESEND_API_KEY=
#- ORIGIN=
#- KENER_SECRET_KEY=
#- RESEND_SENDER_EMAIL=
#- DATABASE_URL=
# If running on a LINUX HOST and not podman rootless these MUST BE SET
# run "id $user" from command line and replace numbers below with output from command
#- PUID=1000 # gid
#- PGID=1000 # uid
### Most likely DO NOT need to change anything below this ###
@@ -26,3 +23,4 @@ services:
- '3000:3000/tcp'
volumes:
- '.:/app/database:rw'
- '.:/app/uploads:rw'
-1255
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -5,6 +5,20 @@ description: Changelogs for Kener
# Changelogs
## v3.0.1
<picture>
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f680/512.webp" type="image/webp">
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f680/512.gif" alt="🚀" width="32" height="32">
</picture>
Here are the changes in this release
### Features
- Support for i18n in dates.
- Support of i18n in monitor embeds. Read more [here](/docs/embed#javascript-parameters)
## v3.0.0
<picture>
+4
View File
@@ -27,6 +27,10 @@ DNS Monitor is used to check the status of a DNS server. It can be configured to
Ping Monitor is used to check the status of a server. It can be configured to check the status of the server at different intervals.
### Data Interpolation
Kener interpolates the data for missing intervals. For example, suppose if you have a cron monitor of 5 minutes and it has run twice at 10AM and 10:05AM and the monitor is `DOWN` at 10AM. Kener will interpolate the data for 10:01AM, 10:02AM, 10:03AM and 10:04AM as `DOWN`. Similarly, if the monitor is UP at 10:05AM, Kener will interpolate the data for 10:06AM, 10:07AM, 10:08AM and 10:09AM as UP. Similarly for missing data points it will consider the previous data point. If there is no previous data point, it will consider it as `UP`.
---
## Triggers
+18
View File
@@ -15,6 +15,10 @@ Add your custom JS to `static/` file. And in the `src/app.html` file, add the fo
## Adding Custom CSS
Three are two ways you can add custom CSS to your Kener instance.
### CSS file
Add your custom CSS to `static/` file. And in the `src/app.html` file, add the following line:
```html
@@ -22,3 +26,17 @@ Add your custom CSS to `static/` file. And in the `src/app.html` file, add the f
```
Do not forget to add the base path if you are using a subpath. For example, if you are using a subpath `/kener`, then the path should be `/kener/your-custom-js-file.js`.
### Inline CSS
To add inline css go to Manage kener -> Theme -> Custom CSS and add your CSS there.
```css
.my-class {
color: red;
}
```
<div class="note danger">
Do not include &#x3C;style&#x3E; tags.
</div>
+54 -7
View File
@@ -47,27 +47,42 @@ docker.io/rajnandan1/kener:latest
ghcr.io/rajnandan1/kener:latest
```
You should mount two host directories to persist your configuration and database. [Environmental variables](/docs/environment-vars) can be passed with `-e` An example `docker run` command:
### Volumes
- `$(pwd)/uploads` You should mount one host directories to persist your image uploads.
- `$(pwd)/database` If you are using sqlite, you should mount one host directory to persist your database.
### Environment Variables
[Environment variables](/docs/environment-vars) can be passed with `-e` An example `docker run` command:
Make sure `./database` directory is present in the root directory.
This example is for sqlite. You can also use postgres. Read more about it [here](/docs/environment-vars/#database-url)
### Examples
This example is for sqlite. You can also use postgres. Read more about it [here](/docs/environment-vars#database-url)
#### sqlite
```bash
mkdir database
docker run \
-v $(pwd)/database:/app/database \
-v $(pwd)/uploads:/app/uploads \
-p 3000:3000 \
-e "KENER_SECRET_KEY=somesecretkey" \
-e "ORIGIN=http://localhost:3000" \
rajnandan1/kener
```
#### .env
You can also use a .env file
```bash
docker run \
-v $(pwd)/database:/app/database \
-v $(pwd)/uploads:/app/uploads \
--env-file .env \
-p 3000:3000 \
rajnandan1/kener
@@ -75,15 +90,47 @@ docker run \
Or use **Docker Compose** with the example [docker-compose.yaml](https://raw.githubusercontent.com/rajnandan1/kener/main/docker-compose.yml)
## Base path
By default kener runs on `/` but you can change it to `/status` or any other path. Read more about it [here](/docs/environment-vars/#kener-base-path)
## Postgres
#### Postgres
```bash
docker run \
-p 3000:3000 \
-v $(pwd)/uploads:/app/uploads \
-e "KENER_SECRET_KEY=somesecretkey" \
-e "DATABASE_URL=postgresql://myuser:mypassword@localhost:5432/mydatabase" \
-e "ORIGIN=http://localhost:3000" \
rajnandan1/kener
```
#### MySQL
```bash
docker run \
-p 3000:3000 \
-v $(pwd)/uploads:/app/uploads \
-e "KENER_SECRET_KEY=somesecretkey" \
-e "DATABASE_URL=mysql://root:password@mysql-container-2.orb.local:3306/kener-2" \
-e "ORIGIN=http://localhost:3000" \
rajnandan1/kener
```
#### Base path
By default kener runs on `/` but you can change it to `/status` or any other path. Read more about it [here](/docs/environment-vars/#kener-base-path).
<div class="note info">
- Important: The base path should _**NOT**_ have a trailing slash and should start with `/`
- Important: This env variable should be present during both build and run time
</div>
Let us say you are running kener on a subpath `/status`. You can set the base path like this:
```bash
docker build --build-arg KENER_BASE_PATH=/status -t kener .
```
```bash
docker run -p 3000:3000 --env-file .env -v $(pwd)/database:/app/uploads -v $(pwd)/database:/app/database kener
```
+4 -2
View File
@@ -33,7 +33,8 @@ You can pass the following parameters to the embed code
- `theme`: You can pass `light` or `dark` theme
- `monitor`: The monitor url
- `bgc`: Background color of the monitor. Only supports hex color codes. DO NOT include the `#` symbol
- `bgc`: Background color of the monitor. Only supports hex color codes. DO NOT include the `#` symbol. Example: `ff0000`
- `locale`: The locale of the monitor. You can pass the code of the locale you have enabled in your kener settings. Example: `en`, `fr` etc
Replace `[hostname]` with your kener hostname and `[tag]` with your monitor tag.
@@ -78,7 +79,8 @@ Replace `[hostname]` with your kener hostname and `[tag]` with your monitor tag.
You can pass the following parameters to the embed code
- `theme`: You can pass `light` or `dark` theme
- `bgc`: Background color of the monitor. Only supports hex color codes. DO NOT include the `#` symbol
- `bgc`: Background color of the monitor. Only supports hex color codes. DO NOT include the `#` symbol. Example: `ff0000`
- `locale`: The locale of the monitor. You can pass the code of the locale you have enabled in your kener settings. Example: `en`, `fr` etc
### Demo
+2 -1
View File
@@ -71,7 +71,6 @@ Kener name is derived from the word "Kene" which means "how is it going" in Assa
- Handles Timezones for visitors
- Categorize Monitors into different Sections
- Cron-based scheduling for monitors. Minimum per minute
- Flexible monitor configuration using YAML
- Construct complex API Polls - Chain, Secrets etc
- Supports a Default Status for Monitors
- Supports base path for hosting in k8s
@@ -91,6 +90,7 @@ Kener name is derived from the word "Kene" which means "how is it going" in Assa
- Incident Management
- Incident Communication
- Comprehensive APIs for Incident Management
### User Experience and Design
@@ -99,6 +99,7 @@ Kener name is derived from the word "Kene" which means "how is it going" in Assa
- User-friendly interface
- Responsive design for various devices
- Auto SEO and Social Media ready
- Server Side Rendering
## Technologies used
+18 -2
View File
@@ -5,7 +5,7 @@ description: Kener supports multiple languages. You can add translations to your
# i18n
You can add translations to your site. By default it is set to `en`. Available translations are present in `/src/lib/locales/` folders in the root directory. You can add more translations by adding a new file in the `/src/lib/locales` folder.
You can add translations to your site. By default it is set to `en`. Available translations are present in `src/lib/locales/` folders in the root directory. You can add more translations by adding a new file in the `src/lib/locales` folder.
## How to enable a translation
@@ -90,7 +90,7 @@ Copy and update the translation file in the `locales` folder. The translation fi
### Step 2
Open `/locales/locales.json` and add the locale code and the name of the language.
Open `src/lib/locales/locales.json` and add the locale code and the name of the language.
```json
[
@@ -123,6 +123,22 @@ Open `/locales/locales.json` and add the locale code and the name of the languag
### Step 3
Open `src/lib/i18n/client.js` and add the locale code to the `locales` array.
```js
import { ru, enUS, hi, de, zhCN, vi, ja, nl, da, fr, ko } from "date-fns/locale";
```
This should be valid for the date-fns library. You can find the list of locales [here](https://github.com/date-fns/date-fns/blob/9bb51691f201c3ec05ab832acbc5d478f2e5c47a/docs/i18nLocales.md).
Next add a mapping in the `locales` object.
```js
const locales = { ru, en: enUS, hi, dk: de, "zh-CN": zhCN, vi, ja, nl, dk: da, fr, ko };
```
### Step 4
Restart the server and you will see a dropdown in the navbar to select the language.
<div class="note info">
+5 -1
View File
@@ -47,7 +47,7 @@ The eval is used to define the JavaScript code that should be used to evaluate t
This is a anonymous JS function, by default it looks like this.
> **_NOTE:_** The eval function should always return a json object. The json object can have only status(UP/DOWN/DEGRADED) and lantecy(number)
> **_NOTE:_** The eval function should always return a json object. The json object can have only status(UP/DOWN/DEGRADED) and latency(number)
> `{status:"DEGRADED", latency: 200}`.
```javascript
@@ -74,6 +74,10 @@ let decodedResp = atob(responseDataBase64);
//let jsonResp = JSON.parse(decodedResp)
```
<div class="note danger">
The eval is validated against (200, 1000, "e30=") which translates to (200, 1000, '{}') within the function. So whatever function you write should be able to handle these values otherwise you won't be able to save the monitor.
</div>
#### Example
The following example shows how to use the eval function to evaluate the response. The function checks if the status code is 2XX then the status is UP, if the status code is 5XX then the status is DOWN. If the response contains the word `Unknown Error` then the status is DOWN. If the response time is greater than 2000 then the status is DEGRADED.
+1 -1
View File
@@ -47,7 +47,7 @@ The image is used to display a logo or an image for the monitor. It is displayed
## Description
The description is used to describe the monitor. It is displayed on the status page and in the admin panel. It is optional.
The description is used to describe the monitor. It is displayed on the status page and in the admin panel. It is optional. You can also write html in the description and can use tailwind classes.
## Cron
+10 -2
View File
@@ -40,7 +40,15 @@ cp .env.example .env
npm run dev
```
Kener Development Server would be running at PORT 3000. Go to [http://localhost:3000](http://localhost:3000)
Kener Development Server would be running at PORT 3000. Go to [http://localhost:3000](http://localhost:3000) to see the Kener in action.
<div class="note info">
Status page will be at [http://localhost:3000](http://localhost:3000)
Status Page Manage Portal will be at [http://localhost:3000/manage/app/site](http://localhost:3000/manage/app/site)
</div>
## Create a new User
@@ -63,6 +71,6 @@ Learn how to configure kener by going through one of the topics
- [Monitors](/docs/monitors): Learn how to set up and work with monitors in kener.
- [Triggers](/docs/triggers): Learn how to set up and work with triggers in kener.
- [Environment Variables](/docs/environment-vars): Learn how to set up and work with environment variables in kener.
- [API](/docs/api): Learn how to use the API in kener.
- [API](/docs/kener-apis): Learn how to use the API in kener.
- [Databases](/docs/database): Learn how to set up and work with databases in kener.
- [Theme](/docs/theme): Learn how to set up and work with theme in kener.
+12 -6
View File
@@ -20,18 +20,24 @@ description: Roadmap for Kener
~~Support for github enterprise~~
☐ Add support for fly.io deployment
☐ Add to digital ocean marketplace
☐ Add to aws marketplace
☐ Add Rate limiting to APIs
☐ Add support for custom themes
~~Handle uploaded images for docker containers~~
☐ Introduce sitemap.xml
~~Migrate from moment to date-fns for status page~~ (moment remains in dashboard. Will remove later)
~~Create Admin UI~~ Released in (3.0.0)
☐ Webhook customization
☐ Monitor Update Screen
☐ i18n for admin dashboard
---
Request a feature or start a discussion [here](https://github.com/rajnandan1/kener/discussions/119)
+14
View File
@@ -90,3 +90,17 @@ Add font url
### Font Family
Add font family name
### Custom CSS
You can add custom CSS to your status page. This will be added to the head of the page. You can add custom CSS to your status page. This will be added to the head of the page.
```css
.my-class {
color: red;
}
```
<div class="note danger">
Do not include &#x3C;style&#x3E; tags.
</div>
+1 -1
View File
@@ -14,7 +14,7 @@
Hello world
<script
async
src="http://localhost:3000/embed/monitor-earth/js?theme=light&monitor=http://localhost:3000/embed/monitor-earth"
src="http://localhost:3000/embed/monitor-earth/js?theme=dark&bgc=111&locale=hi&monitor=http://localhost:3000/embed/monitor-earth"
></script>
</body>
</html>
+6 -4
View File
@@ -25,7 +25,11 @@ app.get(base + "/healthcheck", (req, res) => {
res.end("ok");
});
//part /uploads server static files from static/uploads
app.use(base + "/uploads", express.static("static/uploads"));
//set env variable for upload path
process.env.UPLOAD_PATH = "./uploads";
app.use(base + "/uploads", express.static("uploads"));
try {
const openapiJSON = fs.readFileSync("./openapi.json", "utf-8");
@@ -85,8 +89,6 @@ app.listen(PORT, async () => {
await runMigrations();
await runSeed();
await db.destroy();
Startup();
console.log("Kener is running on port " + PORT + "!");
});
Startup();
+37 -14
View File
@@ -1,12 +1,12 @@
{
"name": "kener",
"version": "3.0.0",
"version": "3.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kener",
"version": "3.0.0",
"version": "3.0.1",
"license": "MIT",
"dependencies": {
"@formkit/auto-animate": "^0.8.2",
@@ -20,6 +20,7 @@
"bits-ui": "^0.9.9",
"clsx": "^2.0.0",
"croner": "^7.0.5",
"date-fns": "^4.1.0",
"dns2": "^2.1.0",
"dotenv": "^16.4.5",
"express": "^4.18.2",
@@ -64,6 +65,7 @@
"svelte": "^4.0.5",
"svelte-awesome-color-picker": "^3.1.4",
"svelte-check": "^3.6.0",
"svelte-dnd-action": "^0.9.55",
"tailwindcss": "^3.3.2",
"typescript": "^5.0.0",
"vite": "^4.4.2"
@@ -2098,6 +2100,23 @@
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/concurrently/node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.21.0"
},
"engines": {
"node": ">=0.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
}
},
"node_modules/config-chain": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
@@ -2283,19 +2302,13 @@
}
},
"node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"dev": true,
"dependencies": {
"@babel/runtime": "^7.21.0"
},
"engines": {
"node": ">=0.11"
},
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-picker-svelte": {
@@ -7003,6 +7016,16 @@
"svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0"
}
},
"node_modules/svelte-dnd-action": {
"version": "0.9.55",
"resolved": "https://registry.npmjs.org/svelte-dnd-action/-/svelte-dnd-action-0.9.55.tgz",
"integrity": "sha512-ljAp1VFwigYCGb+eqnjt+kIkzawGtpUJ/KjapXYkzVZMkoofOWtnU3DVnnx0fo7CpcS4iPfPR7gM6cixh7iQfw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"svelte": ">=3.23.0 || ^5.0.0-next.0"
}
},
"node_modules/svelte-hmr": {
"version": "0.15.3",
"resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.3.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kener",
"version": "3.0.0",
"version": "3.0.2",
"private": false,
"license": "MIT",
"description": "Kener: An open-source Node.js status page application for real-time service monitoring, incident management, and customizable reporting. Simplify service outage tracking, enhance incident communication, and ensure a seamless user experience.",
@@ -59,6 +59,7 @@
"svelte": "^4.0.5",
"svelte-awesome-color-picker": "^3.1.4",
"svelte-check": "^3.6.0",
"svelte-dnd-action": "^0.9.55",
"tailwindcss": "^3.3.2",
"typescript": "^5.0.0",
"vite": "^4.4.2"
@@ -76,6 +77,7 @@
"bits-ui": "^0.9.9",
"clsx": "^2.0.0",
"croner": "^7.0.5",
"date-fns": "^4.1.0",
"dns2": "^2.1.0",
"dotenv": "^16.4.5",
"express": "^4.18.2",
+5 -5
View File
@@ -167,16 +167,16 @@ section {
background: linear-gradient(0deg, var(--degraded-color) 20%, var(--up-color) 1%);
}
.bg-api-degraded-40 {
background: linear-gradient(0deg, var(--degraded-color) 20%, var(--up-color) 1%);
background: linear-gradient(0deg, var(--degraded-color) 40%, var(--up-color) 1%);
}
.bg-api-degraded-60 {
background: linear-gradient(0deg, var(--degraded-color) 20%, var(--up-color) 1%);
background: linear-gradient(0deg, var(--degraded-color) 60%, var(--up-color) 1%);
}
.bg-api-degraded-80 {
background: linear-gradient(0deg, var(--degraded-color) 20%, var(--up-color) 1%);
background: linear-gradient(0deg, var(--degraded-color) 80%, var(--up-color) 1%);
}
.bg-api-degraded-90 {
background: linear-gradient(0deg, var(--degraded-color) 20%, var(--up-color) 1%);
background: linear-gradient(0deg, var(--degraded-color) 90%, var(--up-color) 1%);
}
/*Needed to show markdown properly*/
@@ -761,7 +761,7 @@ textarea::placeholder {
vertical-align: middle;
width: auto;
word-break: keep-all;
z-index: 0;
z-index: 10;
}
@media (min-width: 768px) {
+136 -7
View File
@@ -114,20 +114,21 @@ const allRecordTypes = {
ANY: 255
};
const ValidateIpAddress = function (input) {
// Check if input is a valid IPv4 address
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
// Check if input is a valid IPv4 address with an optional port
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}(:\d{1,5})?$/;
if (ipv4Regex.test(input)) {
return "IPv4";
}
// Check if input is a valid IPv6 address
const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
// Improved IPv6 regex that better handles compressed notation
const ipv6Regex =
/^(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){0,7}:|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){6}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:)|(?:[0-9a-fA-F]{1,4}:){1,7}(?::|:[0-9a-fA-F]{1,4})|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?:(?::[0-9a-fA-F]{1,4}){1,6})|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:))(?::\d{1,5})?$/;
if (ipv6Regex.test(input)) {
return "IPv6";
}
// Check if input is a valid domain name
const domainRegex = /^[a-zA-Z0-9]+([\-\.]{1}[a-zA-Z0-9]+)*\.[a-zA-Z]{2,}$/;
// Check if input is a valid domain name with an optional port
const domainRegex = /^[a-zA-Z0-9]+([\-\.]{1}[a-zA-Z0-9]+)*\.[a-zA-Z]{2,}(:\d{1,5})?$/;
if (domainRegex.test(input)) {
return "Domain Name";
}
@@ -135,6 +136,7 @@ const ValidateIpAddress = function (input) {
// If none of the above conditions match, the input is invalid
return "Invalid";
};
function IsValidHost(domain) {
const regex = /^[a-zA-Z0-9]+([\-\.]{1}[a-zA-Z0-9]+)*\.[a-zA-Z]{2,}$/;
return regex.test(domain);
@@ -147,7 +149,132 @@ function IsValidNameServer(nameServer) {
const IsValidURL = function (url) {
return /^(http|https):\/\/[^ "]+$/.test(url);
};
function ValidateCronExpression(cronExp) {
// Check if expression is provided and is a string
if (!cronExp || typeof cronExp !== "string") {
return { isValid: false, message: "Cron expression must be a non-empty string" };
}
// Split the expression into its components
const fields = cronExp.trim().split(/\s+/);
// Standard cron should have 5 or 6 fields
// minute hour day-of-month month day-of-week [year]
if (fields.length < 5 || fields.length > 6) {
return {
isValid: false,
message: "Cron expression must have 5 or 6 fields"
};
}
// Define field constraints
const fieldConstraints = [
{ name: "minute", min: 0, max: 59 },
{ name: "hour", min: 0, max: 23 },
{ name: "day", min: 1, max: 31 },
{ name: "month", min: 1, max: 12 },
{ name: "weekday", min: 0, max: 6 },
{ name: "year", min: 1970, max: 2099 } // Optional field
];
// Valid characters in cron expressions
const validChars = /^[\d/*,\-]+$/;
// Validate each field
for (let i = 0; i < fields.length; i++) {
const field = fields[i];
const constraint = fieldConstraints[i];
// Check for valid characters
if (!validChars.test(field)) {
return {
isValid: false,
message: `Invalid characters in ${constraint.name} field`
};
}
// Handle special characters
if (field === "*") {
continue; // Asterisk is valid for all fields
}
// Handle lists (comma-separated values)
if (field.includes(",")) {
const values = field.split(",");
for (const value of values) {
if (!isValidRange(value, constraint.min, constraint.max)) {
return {
isValid: false,
message: `Invalid value in ${constraint.name} field: ${value}`
};
}
}
continue;
}
// Handle ranges (with hyphens)
if (field.includes("-")) {
const [start, end] = field.split("-").map(Number);
if (
start == null ||
end == null ||
start < constraint.min ||
end > constraint.max ||
start > end
) {
return {
isValid: false,
message: `Invalid range in ${constraint.name} field: ${field}`
};
}
continue;
}
// Handle steps (with forward slash)
if (field.includes("/")) {
const [range, step] = field.split("/");
if (range !== "*" && !isValidRange(range, constraint.min, constraint.max)) {
return {
isValid: false,
message: `Invalid range in ${constraint.name} field: ${range}`
};
}
if (!isValidRange(step, 1, constraint.max)) {
return {
isValid: false,
message: `Invalid step value in ${constraint.name} field: ${step}`
};
}
continue;
}
// Handle plain numbers
if (!isValidRange(field, constraint.min, constraint.max)) {
return {
isValid: false,
message: `Invalid value in ${constraint.name} field: ${field}`
};
}
}
return { isValid: true, message: "Valid cron expression" };
}
function isValidRange(value, min, max) {
const num = Number(value);
return !isNaN(num) && num >= min && num <= max;
}
function SortMonitor(monitorSort, resp) {
let monitors = [];
if (!!monitorSort && monitorSort.length > 0) {
monitors = monitorSort.map((id) => resp.find((m) => m.id == id)).filter((m) => !!m);
//append any new monitors
monitors = [...monitors, ...resp.filter((m) => !monitorSort.includes(m.id))];
} else {
monitors = resp;
}
return monitors;
}
export {
siteDataExtractFromDb,
storeSiteData,
@@ -155,5 +282,7 @@ export {
ValidateIpAddress,
IsValidHost,
IsValidNameServer,
IsValidURL
IsValidURL,
ValidateCronExpression,
SortMonitor
};
+17 -12
View File
@@ -1,20 +1,23 @@
<script>
import moment from "moment";
import { formatDistanceToNow, formatDistance } from "date-fns";
import * as Accordion from "$lib/components/ui/accordion";
import { l } from "$lib/i18n/client";
import { l, f, fd, fdn } from "$lib/i18n/client";
import { base } from "$app/paths";
export let incident;
export let index;
export let lang;
let startTime = incident.start_date_time;
// let startTime = moment(incident.incident_start_time * 1000).format("MMMM Do YYYY, h:mm:ss a");
let endTime = parseInt(new Date() / 1000);
let nowTime = parseInt(new Date() / 1000);
export let selectedLang = "en";
let startTime = new Date(incident.start_date_time * 1000);
let endTime = new Date();
let nowTime = new Date();
if (incident.end_date_time) {
endTime = incident.end_date_time;
endTime = new Date(incident.end_date_time * 1000);
}
let lastedFor = moment.duration(endTime - startTime, "seconds").humanize();
let startedAt = moment.duration(nowTime - startTime, "seconds").humanize();
const lastedFor = fd(startTime, endTime, selectedLang);
const startedAt = fdn(startTime, selectedLang);
let isFuture = false;
//is future incident
if (nowTime < startTime) {
@@ -94,7 +97,7 @@
</div>
{/if}
<p class="my-3 text-xs font-semibold uppercase text-muted-foreground">
Updates
{l(lang, "Updates")}
</p>
{#if incident.comments.length > 0}
<ol class="relative mt-2 pl-14">
@@ -108,8 +111,10 @@
<time
class=" mb-1 text-sm font-medium leading-none text-muted-foreground"
>
{moment(comment.commented_at * 1000).format(
"MMMM Do YYYY, h:mm:ss a"
{f(
new Date(comment.commented_at * 1000),
"MMMM do yyyy, h:mm:ss a",
selectedLang
)}
</time>
+1 -1
View File
@@ -448,7 +448,7 @@
<Card.Content>
<form class="mx-auto mt-4 space-y-4" on:submit|preventDefault={formSubmiti18n}>
<p class="text-sm font-medium">All available languages</p>
<div class="mb-8 flex w-full flex-row gap-x-4">
<div class="mb-8 flex w-full flex-row flex-wrap gap-x-4">
{#each i18n.locales as locale}
<div>
<label>
+15 -15
View File
@@ -11,6 +11,7 @@
import {
allRecordTypes,
ValidateIpAddress,
ValidateCronExpression,
IsValidHost,
IsValidNameServer
} from "$lib/clientTools.js";
@@ -95,6 +96,7 @@
return false;
}
} catch (error) {
invalidFormMessage = error.message + " in eval.";
return false;
}
return true;
@@ -104,7 +106,6 @@
};
async function saveOrUpdateMonitor() {
console.log(newMonitor);
invalidFormMessage = "";
//tag should alphanumeric hyphen underscore only
const tagRegex = /^[a-zA-Z0-9_-]+$/;
@@ -130,13 +131,11 @@
//validating cron
const cron = newMonitor.cron;
const cronRegex =
/^((\*|([0-5]?\d)(-[0-5]?\d)?(\/[1-9]\d*)?|([0-5]?\d(,[0-5]?\d)*))\s+){4}(\*|([0-7](,[0-7])*|([0-7]-[0-7])))$/;
if (!cronRegex.test(cron)) {
invalidFormMessage = "Invalid Cron";
let cronValidation = ValidateCronExpression(cron);
if (cronValidation.isValid === false) {
invalidFormMessage = "Cron invalid: " + cronValidation.message;
return;
}
//if monitor type is API
if (newMonitor.monitor_type === "API") {
//validating url
@@ -163,7 +162,7 @@
//validating eval
if (!!newMonitor.apiConfig.eval && !isValidEval()) {
invalidFormMessage = "Invalid eval";
invalidFormMessage = invalidFormMessage + "Invalid eval";
return;
}
@@ -176,21 +175,22 @@
let hasV4 = false;
let hasV6 = false;
if (hostsV4 && Array.isArray(hostsV4) && hostsV4.length > 0) {
hostsV4.forEach((host) => {
if (ValidateIpAddress(host) == "Invalid") {
invalidFormMessage = `hostsV4 ${host} is not valid`;
for (let i = 0; i < hostsV4.length; i++) {
if (ValidateIpAddress(hostsV4[i]) == "Invalid") {
invalidFormMessage = `hostsV4 ${hostsV4[i]} is not valid`;
return;
}
});
}
hasV4 = true;
}
if (hostsV6 && Array.isArray(hostsV6) && hostsV6.length > 0) {
hostsV6.forEach((host) => {
if (ValidateIpAddress(host) == "Invalid") {
invalidFormMessage = `hostsV6 ${host} is not valid`;
for (let i = 0; i < hostsV6.length; i++) {
if (ValidateIpAddress(hostsV6[i]) == "Invalid") {
invalidFormMessage = `hostsV6 ${hostsV6[i]} is not valid`;
return;
}
});
}
hasV6 = true;
}
+82 -8
View File
@@ -1,6 +1,6 @@
<script>
import { Button } from "$lib/components/ui/button";
import { Plus, X, Settings, Bell, Loader } from "lucide-svelte";
import { Plus, X, Settings, Bell, Loader, ArrowDownUp, Grip } from "lucide-svelte";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { base } from "$app/paths";
@@ -8,10 +8,14 @@
import { onMount } from "svelte";
import * as Card from "$lib/components/ui/card";
import * as Select from "$lib/components/ui/select";
import { storeSiteData, SortMonitor } from "$lib/clientTools.js";
import { dndzone } from "svelte-dnd-action";
import { flip } from "svelte/animate";
export let categories = [];
export let colorDown = "#777";
export let colorDegraded = "#777";
export let monitorSort = [];
let monitors = [];
let status = "ACTIVE";
let showAddMonitor = false;
@@ -88,11 +92,13 @@
body: JSON.stringify({ action: "getMonitors", data: { status: status } })
});
let resp = await apiResp.json();
monitors = resp.map((m) => {
resp = resp.map((m) => {
m.down_trigger = JSON.parse(m.down_trigger);
m.degraded_trigger = JSON.parse(m.degraded_trigger);
return m;
});
monitors = SortMonitor(monitorSort, resp);
} catch (error) {
alert("Err2or: " + error);
} finally {
@@ -178,6 +184,19 @@
}
shareMenusToggle = true;
}
const flipDurationMs = 200;
function handleSort(e) {
dropTargetStyle = {
border: "1px solid transparent"
};
monitors = e.detail.items;
monitorSort = monitors.map((m) => m.id);
storeSiteData({
monitorSort: JSON.stringify(monitorSort)
});
}
let dropTargetStyle;
let draggableMenu = false;
</script>
{#if showAddMonitor}
@@ -190,7 +209,51 @@
}}
/>
{/if}
{#if draggableMenu}
<div
class="moldal-container fixed left-0 top-0 z-50 h-screen w-full bg-card bg-opacity-30 backdrop-blur-sm"
>
<div
class="absolute left-1/2 top-1/2 h-fit w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-md border bg-background shadow-lg backdrop-blur-lg"
>
<Button
variant="ghost"
on:click={() => {
draggableMenu = false;
}}
class="absolute right-2 top-2 z-40 h-6 w-6 rounded-full border bg-background p-1"
>
<X class="h-4 w-4 text-muted-foreground" />
</Button>
<div class="content px-4 py-4">
<h2 class="text-lg font-semibold">Rearrange Monitors</h2>
<div
class="mt-4"
use:dndzone={{ items: monitors, flipDurationMs, dropTargetStyle }}
on:consider={handleSort}
on:finalize={handleSort}
>
{#each monitors as monitor (monitor.id)}
<div
animate:flip={{ duration: flipDurationMs }}
class="mb-2 rounded-md bg-card p-2"
>
<Grip class="mr-2 inline h-4 w-4" />
{#if !!monitor.image}
<img
src={base + monitor.image}
alt={monitor.name}
class="mr-1 inline-block h-4 w-4"
/>
{/if}
{monitor.name}
</div>
{/each}
</div>
</div>
</div>
</div>
{/if}
<div class="mt-4 flex justify-between">
<div class="flex w-40">
<Select.Root
@@ -219,10 +282,21 @@
<Loader class="ml-2 mt-2 inline h-6 w-6 animate-spin" />
{/if}
</div>
<Button on:click={showAddMonitorSheet}>
<Plus class="mr-2 inline h-6 w-6" />
Add Monitor
</Button>
<div>
{#if status == "ACTIVE"}
<Button
size="icon"
variant="secondary"
on:click={() => (draggableMenu = !draggableMenu)}
>
<ArrowDownUp class=" " />
</Button>
{/if}
<Button on:click={showAddMonitorSheet}>
<Plus class="mr-2 inline h-6 w-6" />
Add Monitor
</Button>
</div>
</div>
<div class="mt-4">
@@ -240,7 +314,7 @@
{monitor.name}
</Card.Title>
{#if !!monitor.description}
<Card.Description>{monitor.description}</Card.Description>
<Card.Description>{@html monitor.description}</Card.Description>
{/if}
<div class="absolute right-2 top-0.5">
<Button
+1 -11
View File
@@ -40,16 +40,6 @@
}
}
// function handleFileChangeLogo(event) {
// const file = event.target.files[0];
// if (file) {
// const reader = new FileReader();
// reader.onload = () => {
// siteInformation.logo = reader.result;
// };
// reader.readAsDataURL(file);
// }
// }
function handleFileChangeFavicon(event) {
const file = event.target.files[0];
if (file) {
@@ -89,7 +79,7 @@
if (response.ok) {
const result = await response.json();
siteInformation.logo = base + "/uploads/" + result.filename;
siteInformation.logo = "/uploads/" + result.filename;
} else {
uploadLogoStatus = "Failed to upload file.";
}
+15 -1
View File
@@ -33,7 +33,8 @@
family: "Albert Sans"
},
colors: "",
font: ""
font: "",
customCSS: ""
};
themeData = siteDataExtractFromDb(data.siteData, themeData);
@@ -351,6 +352,19 @@
/>
</div>
</div>
<hr />
<div class="flex w-full flex-row justify-start gap-2">
<div class="w-full">
<Label for="customCSS" class="text-sm text-muted-foreground">Custom CSS</Label>
<textarea
bind:value={themeData.customCSS}
id="customCSS"
class="h-48 w-full rounded-sm border p-2"
placeholder=".className&#123;color: red;&#125;"
></textarea>
</div>
</div>
<hr />
<div class="flex w-full justify-end">
<Button type="submit" disabled={formState === "loading"}>
Save
+26 -26
View File
@@ -4,6 +4,8 @@
import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button";
import { base } from "$app/paths";
import { sub, startOfDay, getUnixTime } from "date-fns";
import {
Share2,
Link,
@@ -19,10 +21,9 @@
import { createEventDispatcher } from "svelte";
import { afterUpdate } from "svelte";
import axios from "axios";
import { l, summaryTime, n, ampm } from "$lib/i18n/client";
import { l, summaryTime, f } from "$lib/i18n/client";
import { hoverAction, clickOutsideAction, slide } from "svelte-legos";
import LoaderBoxes from "$lib/components/loaderbox.svelte";
import moment from "moment";
import NumberFlow from "@number-flow/svelte";
import Incident from "$lib/components/IncidentNew.svelte";
@@ -33,6 +34,8 @@
export let localTz;
export let lang;
export let embed = false;
export let selectedLang = "en";
let _0Day = {};
let _90Day = monitor.pageData._90Day;
let uptime90Day = monitor.pageData.uptime90Day;
@@ -87,33 +90,31 @@
});
}, 1000 * 0.2);
}
let uptimesRollers = [
{
text: `${l(lang, "90 Days")}`,
startTs: moment().subtract(90, "days").startOf("day").unix(),
startTs: getUnixTime(startOfDay(sub(new Date(), { days: 90 }))),
value: uptime90Day
},
{
text: `${l(lang, "60 Days")}`,
startTs: moment().subtract(59, "days").startOf("day").unix(),
value: uptime90Day
startTs: getUnixTime(startOfDay(sub(new Date(), { days: 59 })))
},
{
text: `${l(lang, "30 Days")}`,
startTs: moment().subtract(29, "days").startOf("day").unix()
startTs: getUnixTime(startOfDay(sub(new Date(), { days: 29 })))
},
{
text: `${l(lang, "14 Days")}`,
startTs: moment().subtract(13, "days").startOf("day").unix()
startTs: getUnixTime(startOfDay(sub(new Date(), { days: 13 })))
},
{
text: `${l(lang, "7 Days")}`,
startTs: moment().subtract(6, "days").startOf("day").unix()
startTs: getUnixTime(startOfDay(sub(new Date(), { days: 6 })))
},
{
text: l(lang, "Today"),
startTs: moment().startOf("day").unix()
startTs: getUnixTime(startOfDay(new Date()))
}
];
@@ -171,7 +172,7 @@
}
let incidentIDs = incidentObj?.ids || [];
dayUptime = "NA";
dateFetchedFor = moment(new Date(bar.timestamp * 1000)).format("dddd, MMMM Do, YYYY");
dateFetchedFor = f(new Date(bar.timestamp * 1000), "EEEE, MMMM do, yyyy", selectedLang);
showDailyDataModal = true;
loadingDayData = true;
dayIncidentsFull = [];
@@ -276,10 +277,7 @@
class="text-api-up truncate text-xs font-semibold text-{monitor.pageData
.summaryColorClass}"
>
{l(lang, summaryTime(monitor.pageData.summaryStatus), {
status: monitor.pageData.summaryStatus,
duration: monitor.pageData.summaryDuration
})}
{monitor.pageData.summaryStatus}
</div>
</div>
</div>
@@ -303,7 +301,6 @@
on:click={(e) => {
dailyDataGetter(e, bar, incidents[ts]);
}}
style="transition: opacity {bar.ij * 2 + 100}ms ease-in;"
href="#"
class="oneline h-[34px] w-[6px]
{bar.border ? 'opacity-100' : 'opacity-20'} pb-1"
@@ -328,13 +325,13 @@
{#if bar.showDetails}
<div class="show-hover absolute text-sm">
<div class="text-{bar.textClass} text-xs font-semibold">
{moment(new Date(bar.timestamp * 1000)).format(
"dddd, MMMM Do, YYYY"
)} -
{l(lang, summaryTime(bar.summaryStatus), {
status: bar.summaryStatus,
duration: bar.summaryDuration
})}
{f(
new Date(bar.timestamp * 1000),
"EEEE, MMMM do, yyyy",
selectedLang
)}
-
{bar.summaryStatus}
</div>
</div>
{/if}
@@ -384,9 +381,12 @@
>
<p>
<span class="text-{bar.cssClass}"></span>
{new Date(
bar.timestamp * 1000
).toLocaleTimeString()}
{f(
new Date(bar.timestamp * 1000),
"hh:mm a",
selectedLang
)}
</p>
{#if bar.status != "NO_DATA"}
<p class="pl-2">
+2 -1
View File
@@ -4,12 +4,13 @@
import { base } from "$app/paths";
import { onMount } from "svelte";
import { analyticsEvent } from "$lib/boringOne";
import { l, summaryTime, n, ampm } from "$lib/i18n/client";
import { l, summaryTime } from "$lib/i18n/client";
import * as RadioGroup from "$lib/components/ui/radio-group";
import { Label } from "$lib/components/ui/label";
export let monitor;
export let lang;
export let selectedLang = "en";
let theme = "light";
let copiedLink = false;
let embedType = "js";
+28 -28
View File
@@ -1,4 +1,31 @@
// @ts-nocheck
import { format, formatDistance, formatDistanceToNow, formatDuration } from "date-fns";
import { ru, enUS, hi, de, zhCN, vi, ja, nl, da, fr, ko, ptBR } from "date-fns/locale";
const locales = { ru, en: enUS, hi, de, "zh-CN": zhCN, vi, ja, nl, dk: da, fr, ko, "pt-BR": ptBR };
const f = function (date, formatStr, locale) {
return format(date, formatStr, {
locale: locales[locale] || enUS
});
};
const fd = function (start, end, locale) {
return formatDistance(start, end, { addSuffix: false, locale: locales[locale] });
};
const fdn = function (start, locale) {
return formatDistanceToNow(start, { addSuffix: true, locale: locales[locale] });
};
const fdm = function (duration, locale) {
return formatDuration(duration, {
format: ["days", "hours", "minutes"],
zero: false,
delimiter: " ",
locale: locales[locale]
});
};
const l = function (sessionLangMap, key, args = {}) {
const keys = key.split(".");
let obj = sessionLangMap;
@@ -9,7 +36,6 @@ const l = function (sessionLangMap, key, args = {}) {
break;
}
}
// Replace placeholders in the string using the args object
if (obj && typeof obj === "string") {
obj = obj.replace(/%\w+/g, (placeholder) => {
@@ -31,30 +57,4 @@ const summaryTime = function (summaryStatus) {
return "%status for %duration";
};
const n = function (
/** @type {{ numbers: { [x: string]: any; }; }} */ sessionLangMap,
/** @type {string} */ inputString
) {
const translations = sessionLangMap.numbers;
console.trace("translations", translations);
// @ts-ignore
return inputString.replace(
/\d/g,
(/** @type {string | number} */ match) => translations[match] || match
);
};
const ampm = function (
/** @type {{ monitor: { [x: string]: any; }; }} */ sessionLangMap,
/** @type {string} */ inputString
) {
const translations = sessionLangMap.monitor;
// @ts-ignore
let resp = inputString.replace(/(am|pm)/g, function (/** @type {string | number} */ match) {
return translations[match] || match;
});
return resp;
};
export { l, summaryTime, n, ampm };
export { l, summaryTime, f, formatDistance, fd, fdn, fdm };
+55
View File
@@ -0,0 +1,55 @@
{
"14 Days": "14 Tage",
"30 Days": "30 Tage",
"60 Days": "60 Tage",
"7 Days": "7 Tage",
"90 Days": "90 Tage",
"Availability per Component": "Verfügbarkeit pro Komponente",
"Back": "Zurück",
"Badge": "Abzeichen",
"Badge Copied": "Abzeichen kopiert",
"Code Copied": "Code kopiert",
"Copy Code": "Code kopieren",
"Copy Link": "Link kopieren",
"Dark": "Dunkel",
"Days": "Tage",
"DEGRADED": "EINGESCHRÄNKT",
"DOWN": "AUSGEFALLEN",
"Embed": "Einbetten",
"Embed this monitor using &#x3C;script&#x3E; or &#x3C;iframe&#x3E; in your app.": "Binden Sie diesen Monitor mit <script> oder <iframe> in Ihre App ein.",
"Get SVG badge for this monitor": "SVG-Abzeichen für diesen Monitor erhalten",
"Get a LIVE Status for this monitor": "LIVE-Status für diesen Monitor erhalten",
"IDENTIFIED": "IDENTIFIZIERT",
"Incident Updates": "Vorfall-Updates",
"INVESTIGATING": "UNTERSUCHUNG",
"Lasted for about %lastedFor": "Dauerte etwa %lastedFor",
"Light": "Hell",
"Link Copied": "Link kopiert",
"LIVE Status": "LIVE-Status",
"Mode": "Modus",
"MONITORING": "ÜBERWACHUNG",
"No Data": "Keine Daten",
"No Incidents": "Keine Vorfälle",
"No Incident On %date": "Kein Vorfall am %date",
"No Monitor Found": "Kein Monitor gefunden",
"No Updates Yet": "Noch keine Updates",
"Ongoing Incidents": "Laufende Vorfälle",
"Pinging": "Pingen",
"Recent Incidents": "Kürzliche Vorfälle",
"RESOLVED": "GELÖST",
"Share": "Teilen",
"Share this monitor using a link with others": "Teilen Sie diesen Monitor mit einem Link mit anderen",
"Standard": "Standard",
"Started about %startedAt ago, lasted for about %lastedFor": "Begann vor etwa %startedAt und dauerte etwa %lastedFor",
"Started about %startedAt ago, still ongoing": "Begann vor etwa %startedAt und dauert noch an",
"Starts in %startedAt": "Beginnt in %startedAt",
"Starts in %startedAt, will last for about %lastedFor": "Beginnt in %startedAt und wird etwa %lastedFor dauern",
"Status": "Status",
"Status OK": "Status OK",
"Theme": "Thema",
"Today": "Heute",
"UP": "VERFÜGBAR",
"Updates": "Updates",
"Uptime": "Verfügbarkeit",
"%status for %duration": "%status für %duration"
}
+55
View File
@@ -0,0 +1,55 @@
{
"14 Days": "14 Dage",
"30 Days": "30 Dage",
"60 Days": "60 Dage",
"7 Days": "7 Dage",
"90 Days": "90 Dage",
"Availability per Component": "Tilgængelighed pr. Komponent",
"Back": "Tilbage",
"Badge": "Mærke",
"Badge Copied": "Mærke Kopieret",
"Code Copied": "Kode Kopieret",
"Copy Code": "Kopier Kode",
"Copy Link": "Kopier Link",
"Dark": "Mørk",
"Days": "Dage",
"DEGRADED": "FORRINGET",
"DOWN": "NEDE",
"Embed": "Integrer",
"Embed this monitor using &#x3C;script&#x3E; or &#x3C;iframe&#x3E; in your app.": "Integrer denne monitor ved at bruge <script> eller <iframe> i din app.",
"Get SVG badge for this monitor": "Hent SVG-mærke til denne monitor",
"Get a LIVE Status for this monitor": "Hent en LIVE-status for denne monitor",
"IDENTIFIED": "IDENTIFICERET",
"Incident Updates": "Hændelsesopdateringer",
"INVESTIGATING": "UNDERSØGER",
"Lasted for about %lastedFor": "Varede i cirka %lastedFor",
"Light": "Lys",
"Link Copied": "Link Kopieret",
"LIVE Status": "LIVE-status",
"Mode": "Tilstand",
"MONITORING": "OVERVÅGNING",
"No Data": "Ingen Data",
"No Incidents": "Ingen Hændelser",
"No Incident On %date": "Ingen Hændelse Den %date",
"No Monitor Found": "Ingen Monitor Fundet",
"No Updates Yet": "Ingen Opdateringer Endnu",
"Ongoing Incidents": "Igangværende Hændelser",
"Pinging": "Pinger",
"Recent Incidents": "Seneste Hændelser",
"RESOLVED": "LØST",
"Share": "Del",
"Share this monitor using a link with others": "Del denne monitor ved hjælp af et link med andre",
"Standard": "Standard",
"Started about %startedAt ago, lasted for about %lastedFor": "Startede for cirka %startedAt siden, varede i cirka %lastedFor",
"Started about %startedAt ago, still ongoing": "Startede for cirka %startedAt siden, stadig igangværende",
"Starts in %startedAt": "Starter om %startedAt",
"Starts in %startedAt, will last for about %lastedFor": "Starter om %startedAt, vil vare i cirka %lastedFor",
"Status": "Status",
"Status OK": "Status OK",
"Theme": "Tema",
"Today": "I dag",
"UP": "OPPE",
"Updates": "Opdateringer",
"Uptime": "Oppetid",
"%status for %duration": "%status i %duration"
}
+55
View File
@@ -0,0 +1,55 @@
{
"14 Days": "14 일",
"30 Days": "30 ",
"60 Days": "60 일",
"7 Days": "7 일",
"90 Days": "90 일",
"Availability per Component": "항목당 가용성",
"Back": "뒤로",
"Badge": "배지",
"Badge Copied": "배지 복사됨",
"Code Copied": "소스코드 복사됨",
"Copy Code": "소스코드 복사",
"Copy Link": "링크 복사",
"Dark": "다크",
"Days": "일",
"DEGRADED": "성능 저하됨",
"DOWN": "중단",
"Embed": "임베드",
"Embed this monitor using &#x3C;script&#x3E; or &#x3C;iframe&#x3E; in your app.": "<script> 나 <iframe> 태그를 사용하여 내 앱에 이 모니터링을 임베딩 해보세요.",
"Get SVG badge for this monitor": "이 모니터링 항목에 대한 SVG 배지를 구해보세요.",
"Get a LIVE Status for this monitor": "이 모니터링 항목에 대한 실시간 상태를 구해보세요.",
"IDENTIFIED": "확인됨",
"Incident Updates": "사건 보고서",
"INVESTIGATING": "조사 중",
"Lasted for about %lastedFor": "%lastedFor 동안 지속",
"Light": "라이트",
"Link Copied": "링크 복사됨",
"LIVE Status": "실시간 상태",
"Mode": "모드",
"MONITORING": "감시 중",
"No Data": "데이터 없음",
"No Incidents": "사건 없음",
"No Incident On %date": "%date 에는 아무 사건도 없었음",
"No Monitor Found": "모니터링 항목을 찾을 수 없음",
"No Updates Yet": "아직 아무 업데이트도 없음",
"Ongoing Incidents": "진행중 사건",
"Pinging": "핑 요청",
"Recent Incidents": "최근 사건",
"RESOLVED": "해결됨",
"Share": "공유",
"Share this monitor using a link with others": "링크를 사용하여 다른 사람들과 이 모니터링 데이터를 공유해보세요",
"Standard": "기본",
"Started about %startedAt ago, lasted for about %lastedFor": "%startedAt 전에 시작되었으며, %lastedFor 동안 지속되었습니다",
"Started about %startedAt ago, still ongoing": "%startedAt 전에 시작되었으며, 아직 진행중입니다",
"Starts in %startedAt": "%startedAt 에 시작되었습니다",
"Starts in %startedAt, will last for about %lastedFor": "%startedAt 전에 시작되었으며, %lastedFor 까지 지속될 예정입니다",
"Status": "상태",
"Status OK": "양호",
"Theme": "테마",
"Today": "오늘",
"UP": "작동 중",
"Updates": "업데이트",
"Uptime": "업타임",
"%status for %duration": "%duration 동안 %status 상태였음"
}
+24
View File
@@ -22,5 +22,29 @@
{
"code": "fr",
"name": "Français"
},
{
"code": "de",
"name": "Deutsch"
},
{
"code": "ru",
"name": "Русский"
},
{
"code": "dk",
"name": "Dansk"
},
{
"code": "nl",
"name": "Nederlands"
},
{
"code": "ko",
"name": "한국어"
},
{
"code": "pt-BR",
"name": "Português Brasileiro"
}
]
+55
View File
@@ -0,0 +1,55 @@
{
"14 Days": "14 dagen",
"30 Days": "30 dagen",
"60 Days": "60 dagen",
"7 Days": "7 dagen",
"90 Days": "90 dagen",
"Availability per Component": "Beschikbaarheid per component",
"Back": "Terug",
"Badge": "Badge",
"Badge Copied": "Badge gekopieerd",
"Code Copied": "Code gekopieerd",
"Copy Code": "Kopieer code",
"Copy Link": "Kopieer link",
"Dark": "Donker",
"Days": "Dagen",
"DEGRADED": "BEPERKT",
"DOWN": "NIET BEREIKBAAR",
"Embed": "Insluiten",
"Embed this monitor using &#x3C;script&#x3E; or &#x3C;iframe&#x3E; in your app.": "Sluit deze monitor in met behulp van <script> of <iframe> in je app.",
"Get SVG badge for this monitor": "SVG-badge ophalen voor deze monitor",
"Get a LIVE Status for this monitor": "LIVE-status ophalen voor deze monitor",
"IDENTIFIED": "GEIDENTIFICEERD",
"Incident Updates": "Incident updates",
"INVESTIGATING": "ONDERZOEK BEZIG",
"Lasted for about %lastedFor": "Duurde ongeveer %lastedFor",
"Light": "Licht",
"Link Copied": "Link gekopieerd",
"LIVE Status": "LIVE status",
"Mode": "Modus",
"MONITORING": "MONITOREN",
"No Data": "Geen gegevens",
"No Incidents": "Geen incidenten",
"No Incident On %date": "Geen incident op %date",
"No Monitor Found": "Geen monitor gevonden",
"No Updates Yet": "Nog geen updates",
"Ongoing Incidents": "Lopende incidenten",
"Pinging": "Pingen",
"Recent Incidents": "Recente incidenten",
"RESOLVED": "OPGELOST",
"Share": "Delen",
"Share this monitor using a link with others": "Deel deze monitor met anderen via een link",
"Standard": "Standaard",
"Started about %startedAt ago, lasted for about %lastedFor": "Begonnen ongeveer %startedAt geleden, duurde ongeveer %lastedFor",
"Started about %startedAt ago, still ongoing": "Begonnen ongeveer %startedAt geleden, nog steeds bezig",
"Starts in %startedAt": "Begint over %startedAt",
"Starts in %startedAt, will last for about %lastedFor": "Begint over %startedAt, zal ongeveer %lastedFor duren",
"Status": "Status",
"Status OK": "Status OK",
"Theme": "Thema",
"Today": "Vandaag",
"UP": "BEREIKBAAR",
"Updates": "Updates",
"Uptime": "Uptime",
"%status for %duration": "%status voor %duration"
}
+55
View File
@@ -0,0 +1,55 @@
{
"14 Days": "14 Dias",
"30 Days": "30 Dias",
"60 Days": "60 Dias",
"7 Days": "7 Dias",
"90 Days": "90 Dias",
"Availability per Component": "Disponibilidade por Componente",
"Back": "Voltar",
"Badge": "Emblema",
"Badge Copied": "Emblema Copiado",
"Code Copied": "Código Copiado",
"Copy Code": "Copiar Código",
"Copy Link": "Copiar Link",
"Dark": "Escuro",
"Days": "Dias",
"DEGRADED": "FALHA",
"DOWN": "INDISPONÍVEL",
"Embed": "Incorporar",
"Embed this monitor using &#x3C;script&#x3E; or &#x3C;iframe&#x3E; in your app.": "Incorporar este monitor ao seu aplicativo utilizando <script> ou <iframe>.",
"Get SVG badge for this monitor": "Obter emblema SVG deste monitor",
"Get a LIVE Status for this monitor": "Obter indicador de status AO VIVO",
"IDENTIFIED": "IDENTIFICADO",
"Incident Updates": "Atualizações do Incidente",
"INVESTIGATING": "INVESTIGANDO",
"Lasted for about %lastedFor": "Durou aproximadamente %lastedFor",
"Light": "Claro",
"Link Copied": "Link Copiado",
"LIVE Status": "Status AO VIVO",
"Mode": "Modo",
"MONITORING": "MONITORANDO",
"No Data": "Sem dados",
"No Incidents": "Sem Incidentes",
"No Incident On %date": "Nenhum incidente em %date",
"No Monitor Found": "Nenhum monitor encontrado",
"No Updates Yet": "Nenhuma atualização ainda",
"Ongoing Incidents": "Incidentes em Andamento",
"Pinging": "Animado",
"Recent Incidents": "Incidentes Recentes",
"RESOLVED": "RESOLVIDO",
"Share": "Compartilhar",
"Share this monitor using a link with others": "Compartilhar este monitor através do link",
"Standard": "Padrão",
"Started about %startedAt ago, lasted for about %lastedFor": "Iniciado há cerca de %startedAt e durou aproximadamente %lastedFor",
"Started about %startedAt ago, still ongoing": "Iniciado há cerca de %startedAt, ainda em andamento",
"Starts in %startedAt": "Inicia em %startedAt",
"Starts in %startedAt, will last for about %lastedFor": "Inícia em %startedAt, com duração de aproximadamente %lastedFor",
"Status": "Status",
"Status OK": "Status OK",
"Theme": "Tema",
"Today": "Hoje",
"UP": "OPERACIONAL",
"Updates": "Atualizações",
"Uptime": "Tempo de Atividade",
"%status for %duration": "%status por %duration"
}
+55
View File
@@ -0,0 +1,55 @@
{
"14 Days": "14 Дней",
"30 Days": "30 Дней",
"60 Days": "60 Дней",
"7 Days": "7 Дней",
"90 Days": "90 Дней",
"Availability per Component": "Доступность по компонентам",
"Back": "Назад",
"Badge": "Значок",
"Badge Copied": "Значок скопирован",
"Code Copied": "Код скопирован",
"Copy Code": "Копировать код",
"Copy Link": "Копировать ссылку",
"Dark": "Темная",
"Days": "Дней",
"DEGRADED": "УХУДШЕНО",
"DOWN": "НЕДОСТУПНО",
"Embed": "Встроить",
"Embed this monitor using &#x3C;script&#x3E; or &#x3C;iframe&#x3E; in your app.": "Встройте этот монитор используя <script> или <iframe> в ваше приложение.",
"Get SVG badge for this monitor": "Получить SVG значок для этого монитора",
"Get a LIVE Status for this monitor": "Получить ЖИВОЙ статус для этого монитора",
"IDENTIFIED": "ВЫЯВЛЕНО",
"Incident Updates": "Обновления инцидента",
"INVESTIGATING": "ИССЛЕДУЕТСЯ",
"Lasted for about %lastedFor": "Длилось около %lastedFor",
"Light": "Светлая",
"Link Copied": "Ссылка скопирована",
"LIVE Status": "ЖИВОЙ статус",
"Mode": "Режим",
"MONITORING": "МОНИТОРИНГ",
"No Data": "Нет данных",
"No Incidents": "Нет инцидентов",
"No Incident On %date": "Нет инцидентов на %date",
"No Monitor Found": "Монитор не найден",
"No Updates Yet": "Пока нет обновлений",
"Ongoing Incidents": "Текущие инциденты",
"Pinging": "Пингуется",
"Recent Incidents": "Недавние инциденты",
"RESOLVED": "РЕШЕНО",
"Share": "Поделиться",
"Share this monitor using a link with others": "Поделитесь этим монитором с другими по ссылке",
"Standard": "Стандартная",
"Started about %startedAt ago, lasted for about %lastedFor": "Началось около %startedAt назад, длилось около %lastedFor",
"Started about %startedAt ago, still ongoing": "Началось около %startedAt назад, все еще продолжается",
"Starts in %startedAt": "Начнется через %startedAt",
"Starts in %startedAt, will last for about %lastedFor": "Начнется через %startedAt, продлится около %lastedFor",
"Status": "Статус",
"Status OK": "Статус ОК",
"Theme": "Тема",
"Today": "Сегодня",
"UP": "РАБОТАЕТ",
"Updates": "Обновления",
"Uptime": "Время работы",
"%status for %duration": "%status в течение %duration"
}
+85 -4
View File
@@ -9,7 +9,8 @@ import {
IsValidI18n,
IsValidAnalytics,
IsValidColors,
IsValidJSONString
IsValidJSONString,
IsValidJSONArray
} from "./validators.js";
import db from "../db/db.js";
import bcrypt from "bcrypt";
@@ -70,6 +71,11 @@ const siteDataKeys = [
isValid: (value) => typeof value === "string",
data_type: "string"
},
{
key: "customCSS",
isValid: (value) => typeof value === "string",
data_type: "string"
},
{
key: "i18n",
isValid: IsValidI18n,
@@ -128,6 +134,11 @@ const siteDataKeys = [
isValid: IsValidJSONString,
data_type: "object"
},
{
key: "monitorSort",
isValid: IsValidJSONArray,
data_type: "object"
},
{
key: "categories",
isValid: IsValidJSONString,
@@ -220,6 +231,17 @@ export const GetTriggerByID = async (id) => {
return await db.getTriggerByID(id);
};
export const GetSiteDataByKey = async (key) => {
let data = await db.getSiteDataByKey(key);
if (!data) {
return null;
}
if (data.data_type == "object") {
return JSON.parse(data.value);
}
return data.value;
};
export const UpdateTriggerData = async (data) => {
return await db.updateMonitorTrigger(data);
};
@@ -365,6 +387,36 @@ export const HashString = (str) => {
return hash.digest("hex");
};
export const InterpolateData = (data, start, anchorStatus, e) => {
let finalData = [];
let status = anchorStatus || "UP";
let end = start;
if (!!data && data.length > 0) {
end = data[data.length - 1].timestamp;
}
if (e) {
end = e;
}
for (let i = start; i <= end; i += 60) {
let nowData = data.find((d) => d.timestamp === i);
if (!!nowData) {
status = nowData.status;
}
finalData.push({ timestamp: i, status: status });
}
return finalData;
};
export const GetLastStatusBefore = async (monitor_tag, timestamp) => {
let data = await db.getLastStatusBefore(monitor_tag, timestamp);
if (data) {
return data.status;
}
return "UP";
};
export const GetDataGroupByDayAlternative = async (
monitor_tag,
start,
@@ -378,7 +430,10 @@ export const GetDataGroupByDayAlternative = async (
const offsetSeconds = offsetMinutes * 60;
const rawData = await db.getDataGroupByDayAlternative(monitor_tag, start, end);
let rawData = await db.getDataGroupByDayAlternative(monitor_tag, start, end);
let anchorStatus = await GetLastStatusBefore(monitor_tag, start);
rawData = InterpolateData(rawData, start, anchorStatus, rawData.length == 0 ? end - 60 : null);
const groupedData = rawData.reduce((acc, row) => {
// Calculate day group considering timezone offset
const dayGroup = Math.floor((row.timestamp + offsetSeconds) / 86400);
@@ -438,9 +493,8 @@ export const CreateIncident = async (data) => {
}
let newIncident = await db.createIncident(incident);
return {
incident_id: newIncident.lastInsertRowid
incident_id: newIncident[0]
};
};
@@ -684,6 +738,33 @@ export const InsertNewAlert = async (data) => {
return await db.getActiveAlert(data.monitor_tag, data.monitor_status, data.alert_status);
};
export const CookieConfig = () => {
//get base path from env
let cookiePath = !!process.env.KENER_BASE_PATH ? process.env.KENER_BASE_PATH : "/";
let isSecuredDomain = false;
if (!!process.env.ORIGIN) {
isSecuredDomain = process.env.ORIGIN.startsWith("https://");
}
return {
name: "kener-user",
secure: isSecuredDomain,
maxAge: 365 * 24 * 60 * 60, // 1 year in seconds
httpOnly: true,
sameSite: "lax",
path: cookiePath
};
};
export const GetLocaleFromCookie = (site, cookies) => {
let selectedLang = site.i18n?.defaultLocale || "en";
const localLangCookie = cookies.get("localLang");
if (!!localLangCookie && site.i18n?.locales.find((l) => l.code === localLangCookie)) {
selectedLang = localLangCookie;
} else if (site.i18n?.defaultLocale && site.i18n?.locales[site.i18n.defaultLocale]) {
selectedLang = site.i18n.defaultLocale;
}
return selectedLang;
};
export const IsLoggedInSession = async (cookies) => {
let tokenData = cookies.get("kener-user");
if (!!!tokenData) {
+10
View File
@@ -37,6 +37,16 @@ export function IsValidJSONString(data) {
return true;
}
//IsValidJSONArray
export function IsValidJSONArray(data) {
try {
data = JSON.parse(data);
} catch (error) {
return false;
}
return Array.isArray(data);
}
export function IsValidNav(nav) {
try {
nav = JSON.parse(nav);
+53 -2
View File
@@ -5,7 +5,8 @@ import { UP, DOWN, DEGRADED } from "./constants.js";
import {
GetMinuteStartNowTimestampUTC,
ReplaceAllOccurrences,
GetRequiredSecrets
GetRequiredSecrets,
Wait
} from "./tool.js";
import alerting from "./alerting.js";
@@ -28,6 +29,11 @@ const alertingQueue = new Queue({
timeout: 10000, // Timeout in ms after which a task will be considered as failed (optional)
autostart: true // Automatically start the queue (optional)
});
const apiQueue = new Queue({
concurrency: 10, // Number of tasks that can run concurrently
timeout: 10000, // Timeout in ms after which a task will be considered as failed (optional)
autostart: true // Automatically start the queue (optional)
});
const defaultEval = `(function (statusCode, responseTime, responseData) {
let statusCodeShort = Math.floor(statusCode/100);
@@ -101,9 +107,13 @@ const pingCall = async (hostsV4, hostsV6) => {
let res = await ping.promise.probe(host);
alive = alive && res.alive;
latencyTotal += res.time;
if (!res.alive) {
throw new Error(JSON.stringify(res));
}
} catch (error) {
alive = alive && false;
latencyTotal += 30;
console.log(`Error in pingCall IP4 for ${host}`, error);
}
}
@@ -115,10 +125,14 @@ const pingCall = async (hostsV4, hostsV6) => {
timeout: false
});
alive = alive && res.alive;
if (!res.alive) {
throw new Error(JSON.stringify(res));
}
latencyTotal += res.time;
} catch (error) {
alive = alive && false;
latencyTotal += 30;
console.log(`Error in pingCall IP6 for ${host}`, error);
}
}
return {
@@ -129,7 +143,7 @@ const pingCall = async (hostsV4, hostsV6) => {
};
const apiCall = async (envSecrets, url, method, headers, body, timeout, monitorEval) => {
let axiosHeaders = {};
axiosHeaders["User-Agent"] = "Kener/2.0.0";
axiosHeaders["User-Agent"] = "Kener/3.0.2";
axiosHeaders["Accept"] = "*/*";
const start = Date.now();
//replace all secrets
@@ -175,6 +189,7 @@ const apiCall = async (envSecrets, url, method, headers, body, timeout, monitorE
statusCode = data.status;
resp = data.data;
} catch (err) {
console.log(`Error in apiCall ${url}`, err.message);
if (err.message.startsWith("timeout of") && err.message.endsWith("exceeded")) {
timeoutError = true;
}
@@ -325,6 +340,36 @@ const Minuter = async (monitor) => {
);
realTimeData[startOfMinute] = apiResponse;
if (apiResponse.type === TIMEOUT) {
apiQueue.push(async (cb) => {
await Wait(4000);
console.log(
"Retrying api call for " +
monitor.name +
" at " +
startOfMinute +
" due to timeout"
);
apiCall(
envSecrets,
monitor.type_data.url,
monitor.type_data.method,
JSON.stringify(monitor.type_data.headers),
monitor.type_data.body,
monitor.type_data.timeout,
monitor.type_data.eval
).then(async (data) => {
await db.insertMonitoringData({
monitor_tag: monitor.tag,
timestamp: startOfMinute,
status: data.status,
latency: data.latency,
type: data.type
});
cb();
});
});
}
} else if (monitor.monitor_type === "PING") {
let pingResponse = await pingCall(monitor.type_data.hostsV4, monitor.type_data.hostsV6);
realTimeData[startOfMinute] = pingResponse;
@@ -386,4 +431,10 @@ alertingQueue.start((err) => {
process.exit(1);
}
});
apiQueue.start((err) => {
if (err) {
console.error("Error occurred:", err);
process.exit(1);
}
});
export { Minuter };
+15
View File
@@ -56,6 +56,16 @@ class DbImpl {
.first();
}
//get the last status before the timestamp given monitor_tag and start timestamp
async getLastStatusBefore(monitor_tag, timestamp) {
return await this.knex("monitoring_data")
.where("monitor_tag", monitor_tag)
.where("timestamp", "<", timestamp)
.orderBy("timestamp", "desc")
.limit(1)
.first();
}
async getDataGroupByDayAlternative(monitor_tag, start, end) {
// Fetch all raw data
//{ timestamp: 1732900380, status: 'UP', latency: 42 }
@@ -196,6 +206,11 @@ class DbImpl {
return await this.knex("site_data").select("value").where("key", key).first();
}
async getSiteDataByKey(key) {
//select all
return await this.knex("site_data").where("key", key).first();
}
//insert into monitors
async insertMonitor(data) {
return await this.knex("monitors").insert({
+2 -8
View File
@@ -41,7 +41,7 @@ const seedSiteData = {
nav: [
{ name: "Documentation", url: "/docs/home", iconURL: "" },
{ name: "Github", iconURL: "", url: "https://github.com/rajnandan1/kener" },
{ name: "Buy me a coffee", iconURL: "", url: "https://buymeacoffee.com/rajnandan1" }
{ name: "Login", iconURL: "", url: "/manage/signin" }
],
hero: {
title: "Kener is a Modern Open-Source Status Page System",
@@ -51,13 +51,7 @@ const seedSiteData = {
'<p class="text-center">\nMade using \n<a href="https://github.com/rajnandan1/kener" target="_blank" rel="noreferrer" class="font-medium underline underline-offset-4">\n Kener\n</a>\nan open source status page system built with Svelte and TailwindCSS.<br/>\nCreated with ❤️ by <a href="https://rajnandan.com" target="_blank" rel="noreferrer" class="font-medium underline underline-offset-4">Raj Nandan Sharma</a>.\n</p>\n',
i18n: {
defaultLocale: "en",
locales: [
{ code: "en", name: "English", selected: true, disabled: false },
{ code: "hi", name: "हिन्दी", selected: true, disabled: false },
{ code: "zh_CN", name: "中文", selected: true, disabled: false },
{ code: "ja", name: "日本語", selected: true, disabled: false },
{ code: "vi", name: "Tiếng Việt", selected: true, disabled: false }
]
locales: [{ code: "en", name: "English", selected: true, disabled: false }]
},
pattern: "none",
analytics: [
+72 -58
View File
@@ -8,17 +8,22 @@ import {
ParseUptime,
GetDayStartTimestampUTC
} from "$lib/server/tool.js";
import { formatDuration, intervalToDuration } from "date-fns";
import { fdm, l, summaryTime } from "$lib/i18n/client";
import {
GetDataGroupByDayAlternative,
InterpolateData,
GetLastStatusBefore
} from "$lib/server/controllers/controller.js";
import { GetDataGroupByDayAlternative } from "$lib/server/controllers/controller.js";
function getSummaryDuration(numOfMinute, selectedLang) {
// Convert minutes to milliseconds and create duration object
const duration = intervalToDuration({
start: new Date(0),
end: new Date(numOfMinute * 60 * 1000)
});
function getSummaryDuration(numOfMinute) {
if (numOfMinute > 59) {
let hour = Math.floor(numOfMinute / 60);
let minute = numOfMinute % 60;
return `${hour} hours ${minute} minute${minute > 1 ? "s" : ""}`;
} else {
return `${numOfMinute} minute${numOfMinute > 1 ? "s" : ""}`;
}
return fdm(duration, selectedLang);
}
function getTimezoneOffset(timeZone) {
const formatter = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "short" });
@@ -32,11 +37,12 @@ function getTimezoneOffset(timeZone) {
return parseInt(hours) * 60 + parseInt(minutes);
}
function returnStatusClass(val, c, barStyle) {
function returnStatusClass(val, total, c, barStyle) {
// return "api-degraded-10";
if (barStyle === undefined || barStyle == "FULL") {
return c;
} else if (barStyle == "PARTIAL") {
let totalHeight = 24 * 60;
let totalHeight = total;
let cl = `api-up`;
if (val > 0 && val <= 0.1 * totalHeight) {
cl = c + "-10";
@@ -70,11 +76,9 @@ function getCountOfSimilarStatuesEnd(arr, statusType) {
return count;
}
const FetchData = async function (site, monitor, localTz) {
const FetchData = async function (site, monitor, localTz, selectedLang, lang) {
const secondsInDay = 24 * 60 * 60;
//get offset from utc in minutes
const now = GetMinuteStartNowTimestampUTC() + 60;
const midnight = BeginningOfDay({ timeZone: localTz });
const midnight90DaysAgo = midnight - 90 * 24 * 60 * 60;
@@ -83,24 +87,6 @@ const FetchData = async function (site, monitor, localTz) {
let offsetInMinutes = parseInt((GetDayStartTimestampUTC(now) - midnight) / 60);
const _90Day = {};
let latestTimestamp = 0;
let ij = 0;
for (let i = midnight90DaysAgo; i < midnightTomorrow; i += secondsInDay) {
_90Day[i] = {
UP: 0,
DEGRADED: 0,
DOWN: 0,
timestamp: i,
cssClass: StatusObj.NO_DATA,
textClass: StatusObj.NO_DATA,
summaryDuration: 0,
summaryStatus: NO_DATA,
message: NO_DATA,
border: true,
ij: ij
};
ij++;
latestTimestamp = i;
}
let dbData = await GetDataGroupByDayAlternative(
monitor.tag,
@@ -112,40 +98,52 @@ const FetchData = async function (site, monitor, localTz) {
let totalDownCount = 0;
let totalUpCount = 0;
let summaryDuration = 0;
let summaryStatus = "UP";
let summaryColorClass = "api-nodata";
for (let i = 0; i < dbData.length; i++) {
let dayData = dbData[i];
let ts = dayData.timestamp;
let cssClass = StatusObj.UP;
let message = "Status OK";
let summaryDuration = 0;
let summaryStatus = "UP";
latestTimestamp = ts;
totalDegradedCount += dayData.DEGRADED;
totalDownCount += dayData.DOWN;
totalUpCount += dayData.UP;
if (dayData.DEGRADED >= monitor.day_degraded_minimum_count) {
cssClass = returnStatusClass(dayData.DEGRADED, StatusObj.DEGRADED, site.barStyle);
summaryDuration = getSummaryDuration(dayData.DEGRADED);
cssClass = returnStatusClass(
dayData.DEGRADED,
dayData.total,
StatusObj.DEGRADED,
site.barStyle
);
summaryDuration = getSummaryDuration(dayData.DEGRADED, selectedLang);
summaryStatus = "DEGRADED";
}
if (dayData.DOWN >= monitor.day_down_minimum_count) {
cssClass = returnStatusClass(dayData.DOWN, StatusObj.DOWN, site.barStyle);
summaryDuration = getSummaryDuration(dayData.DOWN);
cssClass = returnStatusClass(
dayData.DOWN,
dayData.total,
StatusObj.DOWN,
site.barStyle
);
summaryDuration = getSummaryDuration(dayData.DOWN, selectedLang);
summaryStatus = "DOWN";
}
if (!!_90Day[ts]) {
_90Day[ts].timestamp = ts;
_90Day[ts].cssClass = cssClass;
_90Day[ts].summaryDuration = summaryDuration;
_90Day[ts].summaryStatus = summaryStatus;
_90Day[ts].textClass = cssClass.replace(/-\d+$/, "");
}
_90Day[ts] = {
border: true
};
_90Day[ts].timestamp = ts;
_90Day[ts].cssClass = cssClass;
_90Day[ts].summaryStatus = l(lang, summaryTime(summaryStatus), {
status: l(lang, summaryStatus),
duration: summaryDuration
});
_90Day[ts].textClass = cssClass.replace(/-\d+$/, "");
}
let uptime90DayNumerator = totalUpCount + totalDegradedCount;
let uptime90DayDenominator = totalUpCount + totalDownCount + totalDegradedCount;
@@ -156,29 +154,46 @@ const FetchData = async function (site, monitor, localTz) {
}
// return _90Day;
let uptime90Day = ParseUptime(uptime90DayNumerator, uptime90DayDenominator);
if (site.summaryStyle === "CURRENT") {
let todayDataDb = await db.getMonitoringData(
monitor.tag,
latestTimestamp,
latestTimestamp + secondsInDay
);
let summaryDuration = 0;
let summaryStatus = "UP";
let summaryColorClass = "api-nodata";
let todayDataDb = await db.getMonitoringData(monitor.tag, midnight, midnight + secondsInDay);
let anchorStatus = await GetLastStatusBefore(monitor.tag, midnight);
todayDataDb = InterpolateData(
todayDataDb,
midnight,
anchorStatus,
todayDataDb.length == 0 ? midnight + secondsInDay : null
);
if (site.summaryStyle === "CURRENT") {
summaryColorClass = "api-up";
let lastRow = todayDataDb[todayDataDb.length - 1];
if (!!lastRow && lastRow.status == "DEGRADED") {
summaryDuration = getSummaryDuration(
getCountOfSimilarStatuesEnd(todayDataDb, "DEGRADED")
getCountOfSimilarStatuesEnd(todayDataDb, "DEGRADED"),
selectedLang
);
summaryStatus = "DEGRADED";
summaryColorClass = "api-degraded";
}
if (!!lastRow && lastRow.status == "DOWN") {
summaryDuration = getSummaryDuration(getCountOfSimilarStatuesEnd(todayDataDb, "DOWN"));
summaryDuration = getSummaryDuration(
getCountOfSimilarStatuesEnd(todayDataDb, "DOWN"),
selectedLang
);
summaryStatus = "DOWN";
summaryColorClass = "api-down";
}
summaryStatus = l(lang, summaryTime(summaryStatus), {
status: l(lang, summaryStatus),
duration: summaryDuration
});
} else {
let lastData = _90Day[latestTimestamp];
summaryColorClass = lastData.cssClass.replace(/-\d+$/, "");
@@ -188,7 +203,6 @@ const FetchData = async function (site, monitor, localTz) {
return {
_90Day: _90Day,
uptime90Day: uptime90Day,
summaryDuration: summaryDuration,
summaryStatus: summaryStatus,
summaryColorClass: summaryColorClass,
barRoundness: site.barRoundness,
+19 -1
View File
@@ -108,6 +108,17 @@ const BeginningOfDay = (options = {}) => {
);
return dt.getTime() / 1000;
};
const BeginningOfMinute = (options = {}) => {
const { date = new Date(), timeZone } = options;
const parts = Intl.DateTimeFormat("en-US", {
timeZone,
hourCycle: "h23",
second: "numeric"
}).formatToParts(date);
const second = parseInt(parts.find((i) => i.type === "second").value);
const dt = new Date(1000 * Math.floor((date - second * 1000) / 1000));
return dt.getTime() / 1000;
};
const ValidateIpAddress = function (input) {
// Check if input is a valid IPv4 address
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
@@ -276,6 +287,11 @@ function GenerateRandomColor() {
var randomColor = Math.floor(Math.random() * 16777215).toString(16);
return randomColor;
}
//wait for x ms promise
function Wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export {
IsValidURL,
IsValidHTTPMethod,
@@ -299,5 +315,7 @@ export {
ReplaceAllOccurrences,
GetRequiredSecrets,
ValidateMonitorAlerts,
GenerateRandomColor
GenerateRandomColor,
BeginningOfMinute,
Wait
};
+2 -1
View File
@@ -53,6 +53,7 @@
<svelte:window on:pagechange={pageChange} on:rightbar={updateTableOfContents} />
<svelte:head>
<link rel="icon" id="kener-app-favicon" href="{base}/logo96.png" />
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-Q3MLRXCBFT"></script>
<script>
@@ -85,7 +86,7 @@
<img src="https://kener.ing/logo.png" class="h-8 w-8" alt="" />
<span class="text-xl font-medium">Kener Documentation</span>
<span class="me-2 rounded border px-2.5 py-0.5 text-xs font-medium">
v3.0.0
v3.0.2
</span>
</a>
</div>
+5 -11
View File
@@ -11,11 +11,11 @@ import {
export async function load({ params, route, url, cookies, request }) {
let isSetupComplete = await IsSetupComplete();
if (!isSetupComplete) {
throw redirect(302, base + "/setup");
throw redirect(302, base + "/manage/setup");
}
if (process.env.KENER_SECRET_KEY === undefined) {
throw redirect(302, base + "/setup");
throw redirect(302, base + "/manage/setup");
}
let isLoggedIn = await IsLoggedInSession(cookies);
@@ -38,21 +38,15 @@ export async function load({ params, route, url, cookies, request }) {
if (userAgent?.includes("Chrome-Lighthouse") || userAgent?.includes("bot")) {
isBot = true;
}
const query = url.searchParams;
//load all files from lib locales folder
let selectedLang = "en";
const localLangCookie = cookies.get("localLang");
let selectedLang = query.get("locale") ? query.get("locale") : "en";
if (!!localLangCookie && site.i18n?.locales.find((l) => l.code === localLangCookie)) {
selectedLang = localLangCookie;
} else if (site.i18n?.defaultLocale && site.i18n?.locales[site.i18n.defaultLocale]) {
selectedLang = site.i18n.defaultLocale;
}
let embed = false;
if (route.id.endsWith("monitor-[tag]")) {
embed = true;
}
const query = url.searchParams;
const bgc = query.get("bgc") ? "#" + query.get("bgc") : "";
return {
site: site,
@@ -17,7 +17,13 @@ export async function load({ params, route, url, parent }) {
delete monitors[i].api;
delete monitors[i].default_status;
monitors[i].embed = true;
let data = await FetchData(siteData, monitors[i], parentData.localTz);
let data = await FetchData(
siteData,
monitors[i],
parentData.localTz,
parentData.selectedLang,
parentData.lang
);
monitors[i].pageData = data;
monitorsActive.push(monitors[i]);
}
@@ -43,6 +43,7 @@
<Monitor
{monitor}
localTz={data.localTz}
selectedLang={data.selectedLang}
lang={data.lang}
{embed}
on:heightChange={handleHeightChange}
@@ -6,6 +6,7 @@ export async function GET({ url, params }) {
const uriEmbedded = query.get("monitor");
const theme = query.get("theme") || "light";
const bgc = query.get("bgc") || "transparent";
const locale = query.get("locale") || "en";
const uriOriginal = uriEmbedded;
const currentSlug = tag;
@@ -15,7 +16,7 @@ export async function GET({ url, params }) {
var uid = "KENER_" + ~~(new Date().getTime() / 86400000);
var uriOriginal = "${uriOriginal}";
var uriOriginalNoProtocol = uriOriginal.split("//").pop();
var uriEmbedded = "${uriEmbedded}?theme=${theme}&bgc=${bgc}";
var uriEmbedded = "${uriEmbedded}?theme=${theme}&bgc=${bgc}&locale=${locale}";
var currentSlug = "${currentSlug}";
var target = document.querySelector("script[src*='" + uriOriginalNoProtocol + "']");
var iframe = document.createElement("iframe");
+3 -10
View File
@@ -5,7 +5,8 @@ import { base } from "$app/paths";
import {
GetAllSiteData,
IsSetupComplete,
IsLoggedInSession
IsLoggedInSession,
GetLocaleFromCookie
} from "$lib/server/controllers/controller.js";
export async function load({ params, route, url, cookies, request }) {
@@ -36,15 +37,7 @@ export async function load({ params, route, url, cookies, request }) {
isBot = true;
}
//load all files from lib locales folder
let selectedLang = "en";
const localLangCookie = cookies.get("localLang");
if (!!localLangCookie && site.i18n?.locales.find((l) => l.code === localLangCookie)) {
selectedLang = localLangCookie;
} else if (site.i18n?.defaultLocale && site.i18n?.locales[site.i18n.defaultLocale]) {
selectedLang = site.i18n.defaultLocale;
}
let selectedLang = GetLocaleFromCookie(site, cookies);
const query = url.searchParams;
const bgc = query.get("bgc") ? "#" + query.get("bgc") : "";
+33 -17
View File
@@ -16,6 +16,8 @@
let defaultLocaleKey = data.selectedLang;
let defaultTheme = data.site.theme;
const allLocales = data.site.i18n?.locales.filter((locale) => locale.selected === true);
let hasConfiguredAnalytics =
!!data.site.analytics && data.site.analytics.filter((provider) => !!provider.id).length > 0;
function toggleMode() {
if ($mode === "light") {
@@ -32,7 +34,10 @@
if (!allLocales) {
defaultLocaleValue = "English";
} else {
defaultLocaleValue = allLocales.find((locale) => locale.code === defaultLocaleKey).name;
let lclFi = allLocales.find((locale) => locale.code === defaultLocaleKey);
if (lclFi) {
defaultLocaleValue = lclFi.name;
}
}
/**
* @param {string} locale
@@ -48,6 +53,7 @@
}
let Analytics;
onMount(async () => {
let localTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (localTz != data.localTz) {
@@ -62,7 +68,7 @@
//
const providers = data.site.analytics;
const analyticsPlugins = [];
if (providers) {
if (hasConfiguredAnalytics) {
//loop object
for (let i = 0; i < providers.length; i++) {
@@ -97,21 +103,27 @@
}
}
}
Analytics = _analytics.init({
app: "kener",
debug: true,
version: 100,
plugins: analyticsPlugins
});
Analytics.page();
if (hasConfiguredAnalytics) {
Analytics = _analytics.init({
app: "kener",
debug: true,
version: 100,
plugins: analyticsPlugins
});
Analytics.page();
}
if (!!data.bgc && data.bgc[0] == "#") {
document.body.style.backgroundColor = data.bgc;
}
});
function captureAnalytics(e) {
const { event, data } = e.detail;
Analytics.track(event, data);
if (hasConfiguredAnalytics) {
const { event, data } = e.detail;
Analytics.track(event, data);
}
}
let customCSS = `<style>${data.site.customCSS}</style>`;
</script>
<svelte:window on:analyticsEvent={captureAnalytics} />
@@ -123,11 +135,12 @@
<link rel="icon" id="kener-app-favicon" href={data.site.favicon} />
{/if}
<link href={data.site.font.cssSrc} rel="stylesheet" />
{#each Object.entries(data.site.metaTags) as [key, value]}
<meta name={key} content={value} />
{/each}
{#if data.site.analytics && data.site.analytics.length > 0}
{#if data.site.metaTags}
{#each data.site.metaTags as metaTag}
<meta name={metaTag.key} content={metaTag.value} />
{/each}
{/if}
{#if hasConfiguredAnalytics}
<script src="https://unpkg.com/analytics/dist/analytics.min.js"></script>
{#each data.site.analytics as { id, type, script }}
{#if !!id}
@@ -135,6 +148,9 @@
{/if}
{/each}
{/if}
{#if !!data.site.customCSS}
{@html customCSS}
{/if}
</svelte:head>
<ModeWatcher />
<main
@@ -204,7 +220,7 @@
on:click|preventDefault={(e) => {
location.href = `${base}/manage/app/site`;
}}
class="button-77 fixed bottom-8 left-8"
class="button-77 fixed bottom-8 left-8 z-50"
role="button"
>
Manage
+9 -1
View File
@@ -1,6 +1,7 @@
// @ts-nocheck
import { FetchData } from "$lib/server/page";
import { GetMonitors, GetIncidentsOpenHome } from "$lib/server/controllers/controller.js";
import { SortMonitor } from "$lib/clientTools.js";
import moment from "moment";
export async function load({ parent, url }) {
@@ -10,6 +11,7 @@ export async function load({ parent, url }) {
const requiredCategory = query.get("category") || "Home";
const parentData = await parent();
const siteData = parentData.site;
monitors = SortMonitor(siteData.monitorSort, monitors);
const monitorsActive = [];
for (let i = 0; i < monitors.length; i++) {
//only return monitors that have category as home or category is not present
@@ -26,7 +28,13 @@ export async function load({ parent, url }) {
delete monitors[i].api;
delete monitors[i].default_status;
let data = await FetchData(siteData, monitors[i], parentData.localTz);
let data = await FetchData(
siteData,
monitors[i],
parentData.localTz,
parentData.selectedLang,
parentData.lang
);
monitors[i].pageData = data;
monitors[i].activeIncidents = [];
+12 -2
View File
@@ -108,7 +108,12 @@
<Card.Root class="w-full">
<Card.Content class=" newincidents w-full overflow-hidden p-0">
{#each data.unresolvedIncidents as incident, index}
<Incident {incident} lang={data.lang} index="incident-{index}" />
<Incident
{incident}
lang={data.lang}
index="incident-{index}"
selectedLang={data.selectedLang}
/>
{/each}
</Card.Content>
</Card.Root>
@@ -161,6 +166,7 @@
{monitor}
localTz={data.localTz}
lang={data.lang}
selectedLang={data.selectedLang}
/>
{/each}
</Card.Content>
@@ -246,7 +252,11 @@
<X class="h-4 w-4 text-muted-foreground" />
</Button>
<div class="content">
<ShareMenu monitor={activeMonitor} lang={data.lang} />
<ShareMenu
monitor={activeMonitor}
lang={data.lang}
selectedLang={data.selectedLang}
/>
</div>
</div>
</div>
+8 -1
View File
@@ -8,7 +8,12 @@ import {
ParseUptime
} from "$lib/server/tool.js";
import db from "$lib/server/db/db.js";
import { GetAllSiteData, GetIncidentsByIDS } from "$lib/server/controllers/controller.js";
import {
GetAllSiteData,
GetIncidentsByIDS,
InterpolateData,
GetLastStatusBefore
} from "$lib/server/controllers/controller.js";
export async function POST({ request }) {
const payload = await request.json();
@@ -31,6 +36,8 @@ export async function POST({ request }) {
}
let dayData = await db.getMonitoringData(monitor.tag, payload.startTs, end);
let anchorStatus = await GetLastStatusBefore(monitor.tag, start);
dayData = InterpolateData(dayData, payload.startTs, anchorStatus, end);
let siteData = await GetAllSiteData();
let ups = 0;
@@ -8,18 +8,29 @@ import {
ParseUptime
} from "$lib/server/tool.js";
import db from "$lib/server/db/db.js";
import { InterpolateData, GetLastStatusBefore } from "$lib/server/controllers/controller.js";
export async function POST({ request }) {
const payload = await request.json();
const monitor = payload.monitor;
const start = payload.startTs;
let end = GetMinuteStartNowTimestampUTC();
let aggregatedData = await db.getAggregatedMonitoringData(monitor.tag, start, end);
let rawData = await db.getMonitoringData(monitor.tag, start, end);
let anchorStatus = await GetLastStatusBefore(monitor.tag, start);
rawData = InterpolateData(rawData, start, anchorStatus, rawData.length == 0 ? end : null);
let aggregatedData = rawData.reduce(
(acc, row) => {
acc[row.status] = (acc[row.status] || 0) + 1;
return acc;
},
{
UP: 0,
DOWN: 0,
DEGRADED: 0
}
);
//covert all keys to uppercase
aggregatedData = Object.keys(aggregatedData).reduce((acc, key) => {
acc[key.toUpperCase()] = aggregatedData[key];
return acc;
}, {});
let ups = Number(aggregatedData.UP);
let downs = Number(aggregatedData.DOWN);
let degradeds = Number(aggregatedData.DEGRADED);
+2 -2
View File
@@ -2,8 +2,8 @@
import { redirect } from "@sveltejs/kit";
import { base } from "$app/paths";
import moment from "moment";
import { format } from "date-fns";
export async function load({ parent, url }) {
throw redirect(302, base + "/incidents/" + moment().format("MMMM-YYYY"));
throw redirect(302, base + "/incidents/" + format(new Date(), "MMMM-yyyy"));
}
@@ -3,27 +3,38 @@
import {
GetMonitors,
GetIncidentsOpenHome,
GetIncidentsPage
GetIncidentsPage,
GetLocaleFromCookie
} from "$lib/server/controllers/controller.js";
import { BeginningOfDay } from "$lib/server/tool.js";
import db from "$lib/server/db/db.js";
import moment from "moment";
import {
format,
addMonths,
parse,
subMonths,
getUnixTime,
startOfMonth,
endOfMonth
} from "date-fns";
import { f } from "$lib/i18n/client";
export async function load({ parent, url, params }) {
export async function load({ parent, url, params, cookies }) {
const parentData = await parent();
let monitors = await GetMonitors({ status: "ACTIVE" });
const query = url.searchParams;
let todayStart = BeginningOfDay({ timeZone: parentData.localTz });
let tomorrowStart = todayStart + 86400;
let month = params.month;
let locale = GetLocaleFromCookie(parentData.site, cookies);
let month = params.month; //January-2021
if (!!!month) {
month = moment().format("MMMM-YYYY");
month = format(new Date(), "MMMM-yyyy");
}
let monthStartTs = moment(month, "MMMM-YYYY").unix();
let monthStartTs = getUnixTime(startOfMonth(parse(month, "MMMM-yyyy", new Date())));
let monthEndTs = moment(month, "MMMM-YYYY").endOf("month").unix();
let monthEndTs = getUnixTime(endOfMonth(parse(month, "MMMM-yyyy", new Date())));
let incidents = await GetIncidentsPage(monthStartTs, monthEndTs);
@@ -47,8 +58,8 @@ export async function load({ parent, url, params }) {
return {
incidents: incidents,
nextMonthName: moment(month, "MMMM-YYYY").add(1, "months").format("MMMM-YYYY"),
prevMonthName: moment(month, "MMMM-YYYY").subtract(1, "months").format("MMMM-YYYY"),
thisMonthName: month
nextMonthName: f(addMonths(parse(month, "MMMM-yyyy", new Date()), 1), "MMMM-yyyy", "en"),
prevMonthName: f(subMonths(parse(month, "MMMM-yyyy", new Date()), 1), "MMMM-yyyy", "en"),
thisMonthName: f(parse(month, "MMMM-yyyy", new Date()), "MMMM-yyyy", "en")
};
}
@@ -1,14 +1,15 @@
<script>
import moment from "moment";
import { Badge } from "$lib/components/ui/badge";
import Incident from "$lib/components/IncidentNew.svelte";
import { Button } from "$lib/components/ui/button";
import { ArrowRight, ArrowLeft, CalendarCheck2, ChevronLeft } from "lucide-svelte";
import { base } from "$app/paths";
import { goto } from "$app/navigation";
import { l } from "$lib/i18n/client";
import { l, f } from "$lib/i18n/client";
import { startOfDay, addDays, subDays, getUnixTime, parse } from "date-fns";
export let data;
let selectedLang = data.selectedLang;
let incidents = data.incidents;
let incidentSmartDates = {};
@@ -19,25 +20,12 @@
incidents.forEach((incident) => {
let startTime = incident.start_date_time;
let today = moment(startTime * 1000)
.startOf("day")
.unix();
let tomorrow = moment(startTime * 1000)
.add(1, "days")
.startOf("day")
.unix();
let dayAfterTomorrow = moment(startTime * 1000)
.add(2, "days")
.startOf("day")
.unix();
let yesterday = moment(startTime * 1000)
.subtract(1, "days")
.startOf("day")
.unix();
let dayBeforeYesterday = moment(startTime * 1000)
.subtract(2, "days")
.startOf("day")
.unix();
const today = getUnixTime(startOfDay(new Date(startTime * 1000)));
const tomorrow = getUnixTime(startOfDay(addDays(new Date(startTime * 1000), 1)));
const dayAfterTomorrow = getUnixTime(startOfDay(addDays(new Date(startTime * 1000), 2)));
const yesterday = getUnixTime(startOfDay(subDays(new Date(startTime * 1000), 1)));
const dayBeforeYesterday = getUnixTime(startOfDay(subDays(new Date(startTime * 1000), 2)));
if (!incidentSmartDates[today]) {
incidentSmartDates[today] = [];
@@ -81,7 +69,7 @@
>
<div class="blurry-bg mx-auto max-w-3xl text-center text-muted">
<h1 class=" text-5xl font-extrabold leading-tight">
{data.thisMonthName.replace("-", ", ")}
{f(parse(data.thisMonthName, "MMMM-yyyy", new Date()), "MMMM, yyyy", selectedLang)}
</h1>
<p class="mx-auto mt-4 max-w-xl font-medium sm:text-xl">
{l(data.lang, "Incident Updates")}
@@ -119,7 +107,7 @@
{#each sortedIncidentSmartDates as date}
<div class="mb-4 grid w-full grid-cols-2 gap-x-4 rounded-md border bg-card">
<div class="text-md col-span-2 border-b p-2 px-4 font-medium">
{moment.unix(date).format("dddd, MMMM Do")}
{f(new Date(date * 1000), "EEEE, MMMM do", data.selectedLang)}
</div>
{#if incidentSmartDates[date].length === 0}
<div class="col-span-2 p-2 px-4 text-sm font-medium text-muted-foreground">
@@ -128,7 +116,12 @@
{/if}
{#each incidentSmartDates[date] as incident, index}
<div class="newincidents col-span-2">
<Incident {incident} lang={data.lang} index="incident-{index}" />
<Incident
{incident}
lang={data.lang}
index="incident-{index}"
{selectedLang}
/>
</div>
{/each}
</div>
@@ -147,7 +140,7 @@
}}
>
<ArrowLeft class="arrow mr-2 h-4 w-4" />
{data.prevMonthName.replace("-", ", ")}
{f(parse(data.prevMonthName, "MMMM-yyyy", new Date()), "MMMM, yyyy", selectedLang)}
</Button>
<Button
variant="secondary"
@@ -156,7 +149,7 @@
window.location.href = `${base}/incidents/${data.nextMonthName}`;
}}
>
{data.nextMonthName.replace("-", ", ")}
{f(parse(data.nextMonthName, "MMMM-yyyy", new Date()), "MMMM, yyyy", selectedLang)}
<ArrowRight class="arrow ml-2 h-4 w-4" />
</Button>
</div>
@@ -13,7 +13,7 @@
if (data.isSecretSet === false || data.isResendSet === false) {
data.view = "error";
data.error =
"Environment variables are not set. Read the documentation to set them. https://kener.ing/docs/environment-vars";
"Environment variables(RESEND_API_KEY, RESEND_SENDER_EMAIL) are not set. Read the documentation to set them. https://kener.ing/docs/environment-vars";
}
if (data.isSiteURLSet === false) {
@@ -2,7 +2,12 @@
import { json, redirect } from "@sveltejs/kit";
import { base } from "$app/paths";
import db from "$lib/server/db/db.js";
import { HashPassword, GenerateSalt, GenerateToken } from "$lib/server/controllers/controller.js";
import {
HashPassword,
GenerateSalt,
GenerateToken,
CookieConfig
} from "$lib/server/controllers/controller.js";
//function to validate a strong password
/**
@@ -38,7 +43,7 @@ export async function POST({ request, cookies }) {
if (!validatePassword(password)) {
let errorMessage =
"Password must contain at least one digit, one lowercase letter, one uppercase letter, and have a minimum length of 8 characters.";
throw redirect(302, base + "/setup?error=" + errorMessage);
throw redirect(302, base + "/manage/setup?error=" + errorMessage);
}
let user = {
email: email,
@@ -54,12 +59,13 @@ export async function POST({ request, cookies }) {
throw redirect(302, base + "/manage/signin?error=" + errorMessage);
}
let token = await GenerateToken(userDB);
cookies.set("kener-user", token, {
path: "/" + base,
maxAge: 365 * 24 * 60 * 60, // 1 year in seconds
httpOnly: true,
secure: true,
sameSite: "lax"
let cookieConfig = CookieConfig();
cookies.set(cookieConfig.name, token, {
path: cookieConfig.path,
maxAge: cookieConfig.maxAge, // 1 year in seconds
httpOnly: cookieConfig.httpOnly,
secure: cookieConfig.secure,
sameSite: cookieConfig.sameSite
});
throw redirect(302, base + "/");
}
@@ -18,8 +18,10 @@ export async function load({ params, route, url, cookies }) {
throw redirect(302, base + "/manage/app/site");
}
}
let userCount = await db.getUsersCount();
const query = url.searchParams;
return {
error: query.get("error")
error: query.get("error"),
firstUser: userCount.count === 0
};
}
@@ -10,7 +10,8 @@
password: ""
};
export let data = {
error: null
error: null,
firstUser: false
};
</script>
@@ -31,6 +32,30 @@
<Alert.Description>{data.error}</Alert.Description>
</Alert.Root>
{/if}
{#if data.firstUser}
<Alert.Root variant="destructive" class="my-4">
<Alert.Title class="flex justify-center text-center">
<picture>
<source
srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f973/512.webp"
type="image/webp"
/>
<img
src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f973/512.gif"
alt="🥳"
width="32"
height="32"
/>
</picture>
</Alert.Title>
<Alert.Description class="mt-4 text-center font-medium">
It looks like this is the first time you are signing in. Please <a
href="{base}/manage/setup"
class="text-blue-600">sign up</a
> first.
</Alert.Description>
</Alert.Root>
{/if}
<form class="space-y-6" action="{base}/manage/signin/submit" method="POST">
<div>
@@ -2,14 +2,19 @@
import { json, redirect } from "@sveltejs/kit";
import { base } from "$app/paths";
import db from "$lib/server/db/db.js";
import { VerifyPassword, GenerateToken } from "$lib/server/controllers/controller.js";
import { VerifyPassword, GenerateToken, CookieConfig } from "$lib/server/controllers/controller.js";
export async function GET({ request, cookies }) {
//read form post data email and passowrd
//read form post data email and password
let cookieConfig = CookieConfig();
//clear the cookie
cookies.delete("kener-user", {
path: "/" + base
//delete cookie from browser
cookies.set(cookieConfig.name, "", {
path: cookieConfig.path,
maxAge: 0,
httpOnly: cookieConfig.httpOnly,
secure: cookieConfig.secure,
sameSite: cookieConfig.sameSite
});
throw redirect(302, base + "/manage/signin");
@@ -1,9 +1,14 @@
// @ts-nocheck
import { json, redirect } from "@sveltejs/kit";
import { base } from "$app/paths";
import db from "$lib/server/db/db.js";
import { VerifyPassword, GenerateToken, VerifyToken } from "$lib/server/controllers/controller.js";
import {
VerifyPassword,
GenerateToken,
VerifyToken,
CookieConfig
} from "$lib/server/controllers/controller.js";
export async function POST({ request, cookies }) {
//read form post data email and passowrd
const formdata = await request.formData();
@@ -33,13 +38,14 @@ export async function POST({ request, cookies }) {
//generate token
let token = await GenerateToken(userDB);
let cookieConfig = CookieConfig();
//set server side cookie with 1 year expiry
cookies.set("kener-user", token, {
path: "/" + base,
maxAge: 365 * 24 * 60 * 60, // 1 year in seconds
httpOnly: true,
secure: true,
sameSite: "lax"
cookies.set(cookieConfig.name, token, {
path: cookieConfig.path,
maxAge: cookieConfig.maxAge, // 1 year in seconds
httpOnly: cookieConfig.httpOnly,
secure: cookieConfig.secure,
sameSite: cookieConfig.sameSite
});
throw redirect(302, base + "/manage/app/site");
@@ -12,7 +12,7 @@ export async function load({ params, route, url, cookies, request }) {
let siteData = await GetAllSiteData();
//check if user is authenticated using cookies
if (process.env.KENER_SECRET_KEY === undefined) {
throw redirect(302, base + "/setup");
throw redirect(302, base + "/manage/setup");
}
let tokenData = cookies.get("kener-user");
@@ -13,7 +13,6 @@
import { Play, User } from "lucide-svelte";
import { setMode, mode, ModeWatcher } from "mode-watcher";
export let data;
let thisYear = new Date().getFullYear();
function toggleMode() {
if ($mode === "light") {
@@ -107,12 +106,26 @@
<DropdownMenu.Label class="text-xs">My Account</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Item class="text-xs font-semibold">
<a href="{base}/manage/signin/logout">Logout</a>
<a
href="{base}/manage/signin/logout"
on:click={(e) => {
e.preventDefault();
window.location = base + "/manage/signin/logout";
}}>Logout</a
>
</DropdownMenu.Item>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Button variant="secondary" class="ml-4" target="_blank" href="{base}/">
<Button
variant="secondary"
class="ml-4"
href="{base}/"
on:click={(e) => {
e.preventDefault();
window.open(base + "/", "_blank");
}}
>
<Play class="mr-2 h-4 w-4" />
<span>Preview</span>
</Button>
@@ -0,0 +1,8 @@
import { GetSiteDataByKey } from "$lib/server/controllers/controller.js";
export async function load({ parent }) {
let categories = await GetSiteDataByKey("categories");
return {
categories: categories
};
}
@@ -14,7 +14,8 @@
</Button>
</div>
<MonitorsAdd
categories={data.siteData?.categories}
monitorSort={data.siteData?.monitorSort}
categories={data.categories}
colorDown={data.siteData?.colors?.DOWN || "#ca3038"}
colorDegraded={data.siteData?.colors?.DEGRADED || "#e6ca61"}
/>
@@ -38,8 +38,9 @@ export async function POST({ request, cookies }) {
// Read the file as a buffer
const fileBuffer = await imageFile.arrayBuffer();
let uploadPath = process.env.UPLOAD_PATH || "./static/uploads";
// Save the file to the static directory
writeFileSync(`./static/uploads/${filename}`, Buffer.from(fileBuffer));
writeFileSync(`${uploadPath}/${filename}`, Buffer.from(fileBuffer));
// Return a response
return json({ filename });
+1
View File
@@ -0,0 +1 @@
this is the upload directory
+4 -17
View File
@@ -1,27 +1,14 @@
// @ts-nocheck
import dotenv from "dotenv";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
import dotenv from "dotenv";
dotenv.config();
const PORT = Number(process.env.PORT) || 3000;
const base = process.env.KENER_BASE_PATH || "";
export default defineConfig({
logLevel: "silent",
plugins: [
sveltekit(),
{
name: "kener-startup-message",
configureServer(server) {
server.httpServer?.once("listening", () => {
const address = server.httpServer?.address();
const host = "localhost";
const port = address?.port;
console.log("\n🚀 Kener is running");
console.log(`⚙️ Manage Kener: http://${host}:${port}${base}/manage/app/site\n`);
});
}
}
],
plugins: [sveltekit()],
server: {
port: PORT,
watch: {