Merge pull request #348 from rajnandan1/rbac

Features role-based access control and user management
This commit is contained in:
Raj Nandan Sharma
2025-03-24 10:45:39 +05:30
committed by GitHub
119 changed files with 6114 additions and 1859 deletions
+1
View File
@@ -43,6 +43,7 @@ indent_size = 2
indent_style = space
indent_size = 4
trim_trailing_whitespace = false
print_width = 180
# Dockerfile
[Dockerfile*]
@@ -1,13 +0,0 @@
---
name: Create Incident Template
about: Create Incident Template
title: Title of Incident
labels: incident
assignees: ''
---
Your Incident Description goes here. Markdown Supported
[start_datetime:utcSeconds]
[end_datetime:utcSeconds]
+1 -1
View File
@@ -54,7 +54,7 @@
"semi": false,
"tabWidth": 4,
"trailingComma": "none",
"printWidth": 100
"printWidth": 180
}
},
{
-16
View File
@@ -121,22 +121,6 @@ COPY --chown=node:node --from=builder /app/openapi.json ./openapi.json
COPY --chown=node:node --from=builder /app/openapi.yaml ./openapi.yaml
COPY --chown=node:node --from=builder /app/package.json ./package.json
# Install libcap tools before setting capabilities
RUN if [ -f "/etc/alpine-release" ]; then \
apk add --no-cache libcap; \
else \
apt-get update && apt-get install -y libcap2-bin && rm -rf /var/lib/apt/lists/*; \
fi
# Set capabilities for ping (before changing to non-root user)
RUN if [ -f "/etc/alpine-release" ]; then \
# Alpine path
setcap cap_net_raw+ep /bin/ping; \
else \
# Debian path
setcap cap_net_raw+ep /usr/bin/ping; \
fi
# Ensure necessary directories are writable
VOLUME ["/uploads", "/database"]
+40
View File
@@ -7,6 +7,46 @@ description: Changelogs for Kener
Here are the changelogs for Kener. Changelogs are only published when there are new features or breaking changes.
## v3.2.5
<picture>
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f680/512.webp" type="image/webp">
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f680/512.gif" alt="🚀" width="32" height="32">
</picture>
### Features
- Added code editing for all JavaScript and JSON fields for trigger custom body and monitor `evals`.
- Syntax highlighting and error checking for monitor evaluation functions
- Added support for custom Discord and Slack body templates in triggers
- Improved variable templating for all notification types
- Added comprehensive user management with 3 roles: admin, editor, and member. Read more [here](/docs/rbac)
- Self-service profile management for all users
- User activation/deactivation controls for admins
- Added dedicated Badges management page in the admin dashboard
- Site-wide status badges to show overall service health
- Version information automatically pulled from package.json
### Improvements
- Improved build time by migrating lucid-svelte to individual components
### Github Issues Resolved
- Feature: Enhancement of Webhook Custom Body Functionality [#238](https://github.com/rajnandan1/kener/issues/238)
- Feature: Add LDAP for authentication [#210](https://github.com/rajnandan1/kener/issues/210)
- the event created by tigger can't update affects. [#309](https://github.com/rajnandan1/kener/issues/309)
- Multiple account or RBAC [#334](https://github.com/rajnandan1/kener/issues/334)
- The event that has been edited isn't clickable after you save it [#350](https://github.com/rajnandan1/kener/issues/350)
- /app/package.json ERROR on fresh install [#346](https://github.com/rajnandan1/kener/issues/346)
- Site stops when monitor processing throws an error [#345](https://github.com/rajnandan1/kener/issues/345)
- Support for Self Signed Certificates [#351](https://github.com/rajnandan1/kener/issues/351)
- Wrong month is showing up after clicking on the 'Browse Events' link [#349](https://github.com/rajnandan1/kener/issues/349)
### Migration Notes
This update is fully backward compatible with existing installations. User accounts created before this update will retain admin privileges by default.
## v3.2.1
<picture>
+134
View File
@@ -0,0 +1,134 @@
---
title: Kener Role Based Access Control (RBAC)
description: Role-Based Access Control (RBAC) in Kener
---
# Role-Based Access Control (RBAC) in Kener
Kener includes a comprehensive role-based access control system that allows you to manage user permissions and access to various features. This document explains the available roles, their permissions, and how to manage users effectively.
## Available Roles
Kener offers three different roles with varying levels of permissions:
| Role | Description |
| ------ | ------------------------------------------------------------------------------ |
| Admin | Full access to all features including user management and system configuration |
| Editor | Can create and edit monitors, triggers, incidents, and other operational data |
| Member | Read-only access with limited interaction capabilities |
## Role Permissions
### Admin
Admins have unrestricted access to the entire system:
- **User Management**:
- Create, update, and deactivate users
- Change user roles
- Reset passwords for other users
- Send verification emails
- **System Configuration**:
- Configure all site settings
- Manage API keys
- Set up triggers and integrations
- **Operational Access**:
- Full access to create and manage monitors
- Create and update incidents
- Run tests on monitors and triggers
- Configure all notification channels
### Editor
Editors can manage most operational aspects but cannot administer users:
- **Content Management**:
- Create and edit monitors
- Configure monitor settings
- Create and manage triggers
- Create and update incidents
- Add incident updates and comments
- **Limited Access**:
- Cannot manage users
- Cannot change role assignments
- Cannot access certain system-level configurations
### Member
Members have read-only access with minimal interaction capabilities:
- **View Access**:
- View all monitors and their status
- View incidents and their history
- See system configuration (but cannot modify)
- **Limited Interactions**:
- Can test existing triggers but cannot create or edit them
- Cannot create or update incidents
- Cannot modify any system configuration
## Managing Users
### Adding New Users
Only Admins and Editors can add new users to the system:
1. Navigate to the **Users** page in the management dashboard
2. Click the **Add New User** button
3. Fill in the required information:
- Name
- Email
- Password
- Role (Member or Editor)
4. Click **Add User** to create the account
When a new user is added, a verification email can be sent to confirm their email address if email sending is configured.
### User Settings
Admins can manage user accounts through the user settings page:
- **Email Verification**: Send verification emails to users
- **Password Reset**: Reset a user's password
- **Role Management**: Change a user's role between Member and Editor
- **Account Status**: Activate or deactivate user accounts
### Best Practices
1. **Follow the Principle of Least Privilege**:
- Assign the minimum necessary permissions for users to perform their job
- Start users with the Member role and elevate as needed
2. **Regular Access Reviews**:
- Periodically review user access and roles
- Remove access for users who no longer need it
3. **Admin Accounts**:
- Limit the number of Admin accounts
- Use strong passwords for Admin accounts
- Consider using email verification for all users
## Role Limitations
- The Admin role can only be assigned during initial setup or by existing Admins
- Members cannot create or modify content
- Users cannot modify their own role (only an Admin can change roles)
## Email Verification
When email sending is configured, users can verify their email addresses:
1. Admins can send verification emails from the user management interface
2. Users receive an email with a verification link
3. After clicking the link, the user's email is marked as verified
Email verification improves security and ensures that users have provided valid email addresses for notifications.
+5
View File
@@ -28,6 +28,11 @@
"title": "Databases",
"link": "/docs/database",
"file": "/database.md"
},
{
"title": "Access Control",
"link": "/docs/rbac",
"file": "/rbac.md"
}
]
},
+221 -45
View File
@@ -3,7 +3,7 @@ title: Triggers | Kener
description: Learn how to set up and work with triggers in kener.
---
# Triggers
## Triggers
Triggers are used to trigger actions based on the status of your monitors. You can use triggers to send notifications, or call webhooks when a monitor goes down or up.
@@ -27,10 +27,36 @@ The description is used to define the description of the webhook. It is optional
Kener supports the following triggers:
- [Webhook](#webhook)
- [Discord](#discord)
- [Slack](#slack)
- [Email](#email)
- [Webhook](#webhook)
- [Discord](#discord)
- [Slack](#slack)
- [Email](#email)
### Trigger Variables
You can use the following variables in the triggers using mustache syntax.
| Key | Description | Mustache Variable |
| ------------- | ------------------------------------------------------------ | ----------------- |
| site_name | [string] Name of your site | {{site_name}} |
| logo_url | [string] Logo of your site | {{logo_url}} |
| site_url | [string] Url of your site | {{site_url}} |
| alert_name | [string] Name of the alert | {{alert_name}} |
| status | [string] Status of the alert. Can be `TRIGGERED`, `RESOLVED` | {{status}} |
| is_resolved | [bool] True if alert is `RESOLVED` | {{is_resolved}} |
| is_triggered | [bool] True if alert is `TRIGGERED` | {{is_triggered}} |
| description | [string] Description of the alert. | {{description}} |
| action_text | [string] CTA for next action on the alert | {{action_text}} |
| action_url | [string] URL for next action on the alert | {{action_url}} |
| metric | [string] Name of the monitor | {{metric}} |
| severity | [string] Severity of the alert. Can be `critical`, `warn` | {{severity}} |
| id | [string] Id of the alert | {{id}} |
| current_value | [number] Current count of failures | {{current_value}} |
| threshold | [number] Threshold Failue | {{threshold}} |
| source | [string] Source of the alert | {{source}} |
| timestamp | [string] Timestamp **ISO 8601** formatted | {{timestamp}} |
---
## Webhook
@@ -93,24 +119,65 @@ Body of the webhook will be sent as below:
}
```
| Key | Description | Variable |
| --------------------- | ----------------------------------------------------------- | ----------------------------- |
| id | Unique ID of the alert | ${id} |
| alert_name | Name of the alert | ${alert_name} |
| severity | Severity of the alert. Can be `critical`, `warn` | ${severity} |
| status | Status of the alert. Can be `TRIGGERED`, `RESOLVED` | ${status} |
| source | Source of the alert. Can be `Kener` | ${source} |
| timestamp | Timestamp of the alert | ${timestamp} |
| description | Description of the alert. This you can customize. See below | ${description} |
| details | Details of the alert. | - |
| details.metric | Name of the monitor | ${metric} |
| details.current_value | Current value of the monitor | ${current_value} |
| details.threshold | Alert trigger threshold of the monitor | ${threshold} |
| actions | Actions to be taken. Link to view the monitor. | ${action_text}, ${action_url} |
### Custom Body
You can customize the body of the webhook. You can use the variables mentioned above. If you are not using a json body then please make sure you are using the right content-type by setting custom headers. See examples below.
Using [mustache variables](#triggers-trigger-variables) you can customize the body of the webhook. If you are not using a json body then please make sure you are using the right content-type by setting custom headers.
### Examples
#### 1. Telegram
You can use the webhook trigger to send a message to a telegram channel. Enable `Use a custom webhook body`.
Set the URL to `https://api.telegram.org/bot[BOT_TOKEN]/sendMessage`. Replace [BOT_TOKEN] with your bot token.
```json
{
"chat_id": "[CHAT_ID]", // Replace [CHAT_ID] with your chat id
"text": "<b>{{alert_name}}</b>\n\n<b>Severity:</b> <code>{{severity}}</code>\n<b>Status:</b> {{status}}\n<b>Source:</b> Kener\n<b>Time:</b> {{timestamp}}\n\n📌 <b>Details:</b>\n- <b>Metric:</b>{{metric}}\n- <b>Current Value:</b> <code>{{current_value}}</code>\n- <b>Threshold:</b> <code>{{threshold}}</code>\n\n🔍 <a href=\"{{action_url}}\">{{action_text}}</a>",
"parse_mode": "HTML"
}
```
If you want to send a message to a group, then replace `[CHAT_ID]` with the group id.
You can also use environment variables to store the bot token and chat id. In that case the URL will be `https://api.telegram.org/bot$BOT_TOKEN/sendMessage`. In the body you can use `"chat_id": "$CHAT_ID"`. Make sure you have set the `BOT_TOKEN` and `CHAT_ID` in the <a href="/docs/environment-vars#secrets">environment variables</a>.
#### 2. Conditional Webhook
You can use `is_triggered` and `is_resolved` to create conditional webhooks.
```json
{
"title": "Monitor Alert for {{metric}} has been {{#is_triggered}}🔴 Triggered{{/is_triggered}}{{#is_resolved}}🟢 Resolved{{/is_resolved}}. Current value is {{current_value}} and threshold set is {{threshold}}"
}
```
The output will be
```json
{
"title": "Monitor Alert for Mockoon has been 🔴 Triggered. Current value is 1 and threshold set is 1"
}
```
```json
{
"title": "Monitor Alert for Mockoon has been 🟢 Resolved. Current value is 0 and threshold set is 1"
}
```
#### 3. Combing mustache with env variables
Combine the above example with `NODE_ENV` environment variable.
```json
{
"title": "Monitor Alert for {{metric}} in environment $NODE_ENV has been {{#is_triggered}}🔴 Triggered{{/is_triggered}}{{#is_resolved}}🟢 Resolved{{/is_resolved}}. Current value is {{current_value}} and threshold set is {{threshold}}."
}
```
---
## Discord
@@ -149,6 +216,60 @@ The discord message when alert is `RESOLVED` will look like this
![Discord](/documentation/discord_resolved.png)
### Modify Discord Message
You can modify the discord message by using [mustache variables](#triggers-trigger-variables).
```json
{
"username": "{{site_name}}",
"avatar_url": "{{{logo_url}}}",
"content": "## {{alert_name}}\n{{#is_triggered}}🔴 Triggered{{/is_triggered}}{{#is_resolved}}🟢 Resolved{{/is_resolved}}\n{{description}}\nClick [MY CTA]({{{action_url}}}) for more.",
"embeds": [
{
"title": "❌{{alert_name}}❌",
"description": "{{description}}",
"url": "{{{action_url}}}",
"color": "{{#is_triggered}}13250616{{/is_triggered}}{{#is_resolved}}5156244{{/is_resolved}}",
"fields": [
{
"name": "Monitor",
"value": "{{metric}}",
"inline": false
},
{
"name": "Severity",
"value": "{{severity}}",
"inline": false
},
{
"name": "Alert ID",
"value": "{{id}}",
"inline": false
},
{
"name": "Current Value",
"value": "{{current_value}}",
"inline": true
},
{
"name": "Threshold",
"value": "{{threshold}}",
"inline": true
}
],
"footer": {
"text": "{{source}}",
"icon_url": "{{{logo_url}}}"
},
"timestamp": "{{timestamp}}"
}
]
}
```
---
## Slack
Slack triggers are used to send a message to a slack channel when a monitor goes down or up.
@@ -187,6 +308,81 @@ The slack message when alert is `RESOLVED` will look like this
![Slack](/documentation/slack_resolved.png)
### Modify Slack Message
You can modify the slack message by using [mustache variables](#triggers-trigger-variables).
```json
{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "Hello Alert {{alert_name}}",
"emoji": true
}
},
{
"type": "header",
"text": {
"type": "plain_text",
"text": "{{#is_triggered}}🔴 Triggered{{/is_triggered}}{{#is_resolved}}🟢 Resolved{{/is_resolved}}",
"emoji": true
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "{{description}}\n*Source:* {{source}}\n*Severity:* {{severity}}\n*Status:* {{status}}"
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Metric:*\n{{metric}}"
},
{
"type": "mrkdwn",
"text": "*Current Value:*\n{{current_value}}"
},
{
"type": "mrkdwn",
"text": "*Threshold:*\n{{threshold}}"
},
{
"type": "mrkdwn",
"text": "*Environment:*\n$_NODE_ENV"
},
{
"type": "mrkdwn",
"text": "*Timestamp:*\n<!date^{{timestamp_unix}}^{date} at {time}|{{timestamp}}>"
}
]
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {
"type": "plain_text",
"text": "{{action_text}}"
},
"url": "{{{action_url}}}",
"style": "primary"
}
]
}
]
}
```
---
## Email
Email triggers are used to send an email when a monitor goes down or up. Kener supports sending emails via [resend](https://resend.com) or over SMTP.
@@ -205,10 +401,10 @@ To send emails using Resend you just need to set `RESEND_API_KEY` in the environ
To send emails using SMTP, please enter
- Host: SMTP server host
- Port: SMTP server port
- User: SMTP server username
- Password: SMTP server password
- Host: SMTP server host
- Port: SMTP server port
- User: SMTP server username
- Password: SMTP server password
<div class="note danger">
@@ -273,23 +469,3 @@ Click on the ⚙️ to edit the trigger.
You can deactivate the trigger by switching the toggle to off. You cannot send message to a deactivated trigger. Any monitor with this trigger will not send any notifications.
---
## Examples
### Telegram
You can use the webhook trigger to send a message to a telegram channel. Enable `Use a custom webhook body`.
Set the URL to `https://api.telegram.org/bot[BOT_TOKEN]/sendMessage`. Replace [BOT_TOKEN] with your bot token.
```json
{
"chat_id": "[CHAT_ID]", // Replace [CHAT_ID] with your chat id
"text": "<b>${alert_name}</b>\n\n<b>Severity:</b> <code>${severity}</code>\n<b>Status:</b> ${status}\n<b>Source:</b> Kener\n<b>Time:</b> ${timestamp}\n\n📌 <b>Details:</b>\n- <b>Metric:</b>${metric}\n- <b>Current Value:</b> <code>${current_value}</code>\n- <b>Threshold:</b> <code>${threshold}</code>\n\n🔍 <a href=\"${action_url}\">${action_text}</a>",
"parse_mode": "HTML"
}
```
If you want to send a message to a group, then replace `[CHAT_ID]` with the group id.
You can also use environment variables to store the bot token and chat id. In that case the URL will be `https://api.telegram.org/bot$BOT_TOKEN/sendMessage`. In the body you can use `"chat_id": "$CHAT_ID"`. Make sure you have set the `BOT_TOKEN` and `CHAT_ID` in the <a href="/docs/environment-vars#secrets">environment variables</a>.
+16 -17
View File
@@ -1,20 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
body {
background-color: #111;
}
</style>
</head>
<body>
Hello world
<script
async
src="http://localhost:3000/embed/monitor-earth/js?theme=dark&bgc=111&locale=hi&monitor=http://localhost:3000/embed/monitor-earth"
></script>
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
body {
background-color: #111;
}
</style>
</head>
<body>
<script
async
src="http://localhost:3000/embed/monitor-bitbuckettcp/js?bgc=212226&locale=en&theme=dark&monitor=http://localhost:3000/embed/monitor-bitbuckettcp"
></script>
</body>
</html>
@@ -0,0 +1,38 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export function up(knex) {
return knex.schema.createTable("invitations", (table) => {
// Primary key
table.increments("id").primary();
// Core invitation fields
table.string("invitation_token").unique().notNullable();
table.string("invitation_type").notNullable();
table.integer("invited_user_id").nullable();
table.integer("invited_by_user_id").notNullable();
// Additional data fields
table.text("invitation_meta").nullable(); // For storing JSON or other metadata
table.timestamp("invitation_expiry").notNullable();
table.string("invitation_status").notNullable().defaultTo("PENDING");
// Timestamps
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Indexes
table.index("invitation_status");
table.index("invitation_expiry");
table.index(["invited_by_user_id", "invitation_status"]);
});
}
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export function down(knex) {
return knex.schema.dropTableIfExists("invitations");
}
+267 -46
View File
@@ -1,17 +1,21 @@
{
"name": "kener",
"version": "3.1.8",
"version": "3.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kener",
"version": "3.1.8",
"version": "3.2.5",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.10",
"@codemirror/lang-javascript": "^6.2.3",
"@codemirror/lang-json": "^6.0.1",
"@formkit/auto-animate": "^0.8.2",
"@number-flow/svelte": "^0.2.1",
"@scalar/express-api-reference": "^0.4.167",
"@uiw/codemirror-theme-github": "^4.23.10",
"analytics": "^0.8.14",
"axios": "^1.6.2",
"badge-maker": "^3.3.1",
@@ -31,11 +35,12 @@
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"knex": "^3.1.0",
"lucide-svelte": "^0.292.0",
"lucide-svelte": "^0.483.0",
"marked": "^11.1.1",
"mode-watcher": "^0.4.1",
"moment": "^2.29.4",
"moment-timezone": "^0.5.43",
"mustache": "^4.2.0",
"mysql2": "^3.12.0",
"node-cache": "^5.1.2",
"nodemailer": "^6.10.0",
@@ -46,9 +51,11 @@
"queue": "^7.0.0",
"randomstring": "^1.3.0",
"resend": "^4.0.1",
"svelte-codemirror-editor": "^1.4.1",
"svelte-legos": "^0.2.5",
"tailwind-merge": "^2.0.0",
"tailwind-variants": "^0.1.18"
"tailwind-variants": "^0.1.18",
"vite-plugin-package-version": "^1.1.0"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^2.0.0",
@@ -60,7 +67,6 @@
"concurrently": "^8.2.2",
"cross-env": "^7.0.3",
"date-picker-svelte": "^2.15.1",
"mustache": "^4.2.0",
"postcss": "^8.4.24",
"postcss-load-config": "^4.0.1",
"prettier": "^3.2.5",
@@ -176,10 +182,9 @@
"license": "MIT"
},
"node_modules/@babel/runtime": {
"version": "7.26.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz",
"integrity": "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==",
"dev": true,
"version": "7.26.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
"integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
@@ -188,6 +193,113 @@
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.18.6",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz",
"integrity": "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.0.tgz",
"integrity": "sha512-q8VPEFaEP4ikSlt6ZxjB3zW72+7osfAYW9i8Zu943uqbKuz6utc1+F170hyLUCUltXORjQXRyYQNfkckzA/bPQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.3.tgz",
"integrity": "sha512-8PR3vIWg7pSu7ur8A07pGiYHgy3hHj+mRYRCSG8q+mPIrl0F02rgpGv+DsQTHRTc30rydOsf5PZ7yjKFg2Ackw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.1.tgz",
"integrity": "sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.0.tgz",
"integrity": "sha512-A7+f++LodNNc1wGgoRDTt78cOwWm9KVezApgjOMp1W4hM0898nsqBXwF+sbePE7ZRcjN7Sa1Z5m2oN27XkmEjQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.1.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.8.4",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.4.tgz",
"integrity": "sha512-u4q7PnZlJUojeRe8FJa/njJcMctISGgPQ4PnWsd9268R4ZTtU+tfFYmwkBvgcrK2+QQ8tYFVALVb5fVJykKc5A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.5.10",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.10.tgz",
"integrity": "sha512-RMdPdmsrUf53pb2VwflKGHEe1XVM07hI7vV2ntgw1dmqhimpatSJKva4VA9h4TLUDOD4EIF02201oZurpnEFsg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.36.4",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.36.4.tgz",
"integrity": "sha512-ZQ0V5ovw/miKEXTvjgzRyjnrk9TwriUB1k4R5p7uNnHR9Hus+D1SXHGdJshijEzPFjU25xea/7nhIeSqYFKdbA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.5.0",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
@@ -195,7 +307,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -212,7 +323,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -229,7 +339,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -246,7 +355,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -263,7 +371,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -280,7 +387,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -297,7 +403,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -314,7 +419,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -331,7 +435,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -348,7 +451,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -365,7 +467,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -382,7 +483,6 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -399,7 +499,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -416,7 +515,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -433,7 +531,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -450,7 +547,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -467,7 +563,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -484,7 +579,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -501,7 +595,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -518,7 +611,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -535,7 +627,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -552,7 +643,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -756,6 +846,52 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz",
"integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
"integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.4.21",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.21.tgz",
"integrity": "sha512-lL+1fcuxWYPURMM/oFZLEDm0XuLN128QPV+VuGtKpeaOGdcl9F2LYC3nh1S9LkPqx9M0mndZFdXCipNAZpzIkQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz",
"integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
@@ -776,6 +912,12 @@
"node-pre-gyp": "bin/node-pre-gyp"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@melt-ui/svelte": {
"version": "0.61.2",
"resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.61.2.tgz",
@@ -1204,6 +1346,37 @@
"dev": true,
"license": "MIT"
},
"node_modules/@uiw/codemirror-theme-github": {
"version": "4.23.10",
"resolved": "https://registry.npmjs.org/@uiw/codemirror-theme-github/-/codemirror-theme-github-4.23.10.tgz",
"integrity": "sha512-jTg2sHAcU1d+8x0O+EBDI71rtJ8PWKIW8gzy+SW4wShQTAdsqGHk5y1ynt3KIeoaUkqngLqAK4SkhPaUKlqZqg==",
"license": "MIT",
"dependencies": {
"@uiw/codemirror-themes": "4.23.10"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
}
},
"node_modules/@uiw/codemirror-themes": {
"version": "4.23.10",
"resolved": "https://registry.npmjs.org/@uiw/codemirror-themes/-/codemirror-themes-4.23.10.tgz",
"integrity": "sha512-dU0UgEEgEXCAYpxuVDQ6fovE82XsqgHZckTJOH6Bs8xCi3Z7dwBKO4pXuiA8qGDwTOXOMjSzfi+pRViDm7OfWw==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@codemirror/language": ">=6.0.0",
"@codemirror/state": ">=6.0.0",
"@codemirror/view": ">=6.0.0"
}
},
"node_modules/@unhead/schema": {
"version": "1.11.18",
"resolved": "https://registry.npmjs.org/@unhead/schema/-/schema-1.11.18.tgz",
@@ -1525,9 +1698,9 @@
}
},
"node_modules/axios": {
"version": "1.7.9",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
"integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
@@ -2095,6 +2268,22 @@
"@types/estree": "^1.0.0"
}
},
"node_modules/codemirror": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz",
"integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2267,6 +2456,12 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/croner": {
"version": "7.0.8",
"resolved": "https://registry.npmjs.org/croner/-/croner-7.0.8.tgz",
@@ -2994,7 +3189,6 @@
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
"integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -4779,12 +4973,12 @@
}
},
"node_modules/lucide-svelte": {
"version": "0.292.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.292.0.tgz",
"integrity": "sha512-bnTpg9pbm6pQDc+YiLK2yxtRFk2Cc+hbzwjAPaV85k56x10CJ9LsXjon6wRrlNTSdxJR7GOsRjz0A5ZNu3Z7dg==",
"version": "0.483.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.483.0.tgz",
"integrity": "sha512-MyMgEVLlFfPbyodGpkB+KCpyPkpjI7EKiFw1crA92B1ZXRK5hq5vTsGWAm9Nt3GAKHunoNc5MVsq3EOCz0DZSQ==",
"license": "ISC",
"peerDependencies": {
"svelte": ">=3 <5"
"svelte": "^3 || ^4 || ^5.0.0-next.42"
}
},
"node_modules/magic-string": {
@@ -5106,7 +5300,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
"dev": true,
"license": "MIT",
"bin": {
"mustache": "bin/mustache"
@@ -6337,9 +6530,9 @@
"license": "MIT"
},
"node_modules/prismjs": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz",
"integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==",
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
@@ -6624,7 +6817,6 @@
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"dev": true,
"license": "MIT"
},
"node_modules/regexp.prototype.flags": {
@@ -6771,7 +6963,6 @@
"version": "3.29.5",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz",
"integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==",
"dev": true,
"license": "MIT",
"bin": {
"rollup": "dist/bin/rollup"
@@ -7584,6 +7775,12 @@
"node": ">=0.10.0"
}
},
"node_modules/style-mod": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz",
"integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==",
"license": "MIT"
},
"node_modules/sucrase": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
@@ -7794,6 +7991,16 @@
"svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0"
}
},
"node_modules/svelte-codemirror-editor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/svelte-codemirror-editor/-/svelte-codemirror-editor-1.4.1.tgz",
"integrity": "sha512-Pv350iro0Y/AZTT/y2OLaonheQqAwl50Hdfipa2Jv1Z04TSP5kPUyxQnRjqxeRW7DXOX9s5Nd11tHdBl9iYSzw==",
"license": "MIT",
"peerDependencies": {
"codemirror": "^6.0.0",
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0"
}
},
"node_modules/svelte-dnd-action": {
"version": "0.9.57",
"resolved": "https://registry.npmjs.org/svelte-dnd-action/-/svelte-dnd-action-0.9.57.tgz",
@@ -8419,7 +8626,6 @@
"version": "4.5.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.9.tgz",
"integrity": "sha512-qK9W4xjgD3gXbC0NmdNFFnVFLMWSNiR3swj957yutwzzN16xF/E7nmtAyp1rT9hviDroQANjE4HK3H4WqWdFtw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.18.10",
@@ -8471,6 +8677,15 @@
}
}
},
"node_modules/vite-plugin-package-version": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/vite-plugin-package-version/-/vite-plugin-package-version-1.1.0.tgz",
"integrity": "sha512-TPoFZXNanzcaKCIrC3e2L/TVRkkRLB6l4RPN/S7KbG7rWfyLcCEGsnXvxn6qR7fyZwXalnnSN/I9d6pSFjHpEA==",
"license": "MIT",
"peerDependencies": {
"vite": ">=2.0.0-beta.69"
}
},
"node_modules/vitefu": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz",
@@ -8486,6 +8701,12 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+9 -3
View File
@@ -52,7 +52,6 @@
"concurrently": "^8.2.2",
"cross-env": "^7.0.3",
"date-picker-svelte": "^2.15.1",
"mustache": "^4.2.0",
"postcss": "^8.4.24",
"postcss-load-config": "^4.0.1",
"prettier": "^3.2.5",
@@ -68,9 +67,13 @@
},
"type": "module",
"dependencies": {
"@babel/runtime": "^7.26.10",
"@codemirror/lang-javascript": "^6.2.3",
"@codemirror/lang-json": "^6.0.1",
"@formkit/auto-animate": "^0.8.2",
"@number-flow/svelte": "^0.2.1",
"@scalar/express-api-reference": "^0.4.167",
"@uiw/codemirror-theme-github": "^4.23.10",
"analytics": "^0.8.14",
"axios": "^1.6.2",
"badge-maker": "^3.3.1",
@@ -90,11 +93,12 @@
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"knex": "^3.1.0",
"lucide-svelte": "^0.292.0",
"lucide-svelte": "^0.483.0",
"marked": "^11.1.1",
"mode-watcher": "^0.4.1",
"moment": "^2.29.4",
"moment-timezone": "^0.5.43",
"mustache": "^4.2.0",
"mysql2": "^3.12.0",
"node-cache": "^5.1.2",
"nodemailer": "^6.10.0",
@@ -105,9 +109,11 @@
"queue": "^7.0.0",
"randomstring": "^1.3.0",
"resend": "^4.0.1",
"svelte-codemirror-editor": "^1.4.1",
"svelte-legos": "^0.2.5",
"tailwind-merge": "^2.0.0",
"tailwind-variants": "^0.1.18"
"tailwind-variants": "^0.1.18",
"vite-plugin-package-version": "^1.1.0"
},
"engines": {
"node": ">=20.0.0"
+34
View File
@@ -905,3 +905,37 @@ textarea::placeholder {
content: ".";
}
}
.dark {
--cp-bg-color: hsl(var(--background) / var(--tw-bg-opacity, 1));
--cp-border-color: hsl(var(--border) / var(--tw-border-opacity));
--cp-text-color: hsl(var(--card-foreground) / var(--tw-text-opacity, 1));
}
.color-picker .container {
padding-left: 0px;
padding-right: 0px;
}
.color-picker .color {
border: 1px solid hsl(var(--border) / var(--tw-border-opacity));
}
.color-picker button {
display: none;
}
.color-picker [aria-label="color picker"] input {
color: hsl(var(--foreground) / var(--tw-border-opacity)) !important;
background-color: hsl(var(--input) / var(--tw-text-opacity, 1)) !important;
}
.dark .color-picker {
--cp-bg-color: hsl(var(--background) / var(--tw-bg-opacity, 1));
--cp-border-color: hsl(var(--border) / var(--tw-border-opacity));
--cp-text-color: hsl(var(--card-foreground) / var(--tw-text-opacity, 1));
--cp-input-color: hsl(var(--input) / var(--tw-text-opacity, 1));
--cp-button-hover-color: hsl(var(--input) / var(--tw-text-opacity, 1));
}
.like-input {
background-color: rgba(0, 0, 0, 0.02) !important;
}
.dark .like-input {
background-color: rgba(0, 0, 0, 0.1) !important;
}
+100
View File
@@ -0,0 +1,100 @@
<script>
import { onMount, onDestroy, beforeUpdate } from "svelte";
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
export let monitor;
export let theme = "light";
export let bgc = "transparent";
export let locale = "en";
let iframe, listeners;
let containerId = `embed-container-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
let isMounted = false;
let prevMonitor, prevTheme, prevBgc, prevLocale;
onMount(() => {
if (!monitor) return;
isMounted = true;
prevMonitor = monitor;
prevTheme = theme;
prevBgc = bgc;
prevLocale = locale;
const container = document.getElementById(containerId);
if (!container) return;
const uid = "KENER_" + ~~(new Date().getTime() / 86400000);
const uriEmbedded = `${monitor}?theme=${theme}&bgc=${bgc}&locale=${locale}`;
iframe = document.createElement("iframe");
iframe.src = uriEmbedded;
iframe.id = uid;
iframe.width = "0%";
iframe.height = "0";
iframe.frameBorder = "0";
iframe.allowTransparency = true;
iframe.sandbox =
"allow-modals allow-forms allow-same-origin allow-scripts allow-popups allow-top-navigation-by-user-activation allow-downloads";
iframe.allow = "midi; geolocation; microphone; camera; display-capture; encrypted-media;";
container.appendChild(iframe);
const setHeight = (data) => {
if (data.height !== undefined) {
iframe.height = data.height;
}
};
const setWidth = (data) => {
if (data.width !== undefined) {
iframe.width = data.width;
}
};
listeners = (event) => {
if (event.data && event.data.height !== undefined) {
setHeight(event.data);
}
if (event.data && event.data.width !== undefined) {
setWidth(event.data);
}
};
window.addEventListener("message", listeners, false);
});
beforeUpdate(() => {
if (
isMounted &&
iframe &&
(monitor !== prevMonitor || theme !== prevTheme || bgc !== prevBgc || locale !== prevLocale)
) {
iframe.src = `${monitor}?theme=${theme}&bgc=${bgc}&locale=${locale}`;
// Update previous values
prevMonitor = monitor;
prevTheme = theme;
prevBgc = bgc;
prevLocale = locale;
dispatch("update", {
monitor: prevMonitor,
theme: prevTheme,
bgc: prevBgc,
locale: prevLocale
});
}
});
onDestroy(() => {
if (iframe) {
window.removeEventListener("message", listeners);
iframe.remove();
}
});
</script>
<!-- Dynamic container with unique ID -->
<div id={containerId}></div>
+194
View File
@@ -0,0 +1,194 @@
export const DiscordJSONTemplate = JSON.stringify(
{
username: "{{site_name}}",
avatar_url: "{{{logo_url}}}",
content:
"## {{alert_name}}\n{{#is_triggered}}🔴 Triggered{{/is_triggered}}{{#is_resolved}}🟢 Resolved{{/is_resolved}}\n{{description}}\nClick [{{action_text}}]({{{action_url}}}) for more.",
embeds: [
{
title: "{{alert_name}}",
description: "{{description}}",
url: "{{{action_url}}}",
color: "{{#is_triggered}}13250616{{/is_triggered}}{{#is_resolved}}5156244{{/is_resolved}}",
fields: [
{
name: "Monitor",
value: "{{metric}}",
inline: false,
},
{
name: "Severity",
value: "{{severity}}",
inline: false,
},
{
name: "Alert ID",
value: "{{id}}",
inline: false,
},
{
name: "Current Value",
value: "{{current_value}}",
inline: true,
},
{
name: "Threshold",
value: "{{threshold}}",
inline: true,
},
],
footer: {
text: "{{source}}",
icon_url: "{{{logo_url}}}",
},
timestamp: "{{timestamp}}",
},
],
},
null,
2,
);
export const WebhookJSONTemplate = JSON.stringify(
{
id: "{{id}}",
alert_name: "{{alert_name}}",
severity: "{{severity}}",
status: "{{status}}",
source: "{{source}}",
timestamp: "{{timestamp}}",
description: "{{description}}",
details: {
metric: "{{metric}}",
current_value: "{{current_value}}",
threshold: "{{threshold}}",
},
actions: [
{
text: "{{action_text}}",
url: "{{action_url}}",
},
],
},
null,
2,
);
export const SlackJSONTemplate = JSON.stringify(
{
blocks: [
{
type: "header",
text: {
type: "plain_text",
text: "{{alert_name}}",
emoji: true,
},
},
{
type: "header",
text: {
type: "plain_text",
text: "{{#is_triggered}}🔴 Triggered{{/is_triggered}}{{#is_resolved}}🟢 Resolved{{/is_resolved}}",
emoji: true,
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: "{{description}}\n*Source:* {{source}}\n*Severity:* {{severity}}\n*Status:* {{status}}",
},
},
{
type: "section",
fields: [
{
type: "mrkdwn",
text: "*Metric:*\n{{metric}}",
},
{
type: "mrkdwn",
text: "*Current Value:*\n{{current_value}}",
},
{
type: "mrkdwn",
text: "*Threshold:*\n{{threshold}}",
},
{
type: "mrkdwn",
text: "*Timestamp:*\n<!date^{{timestamp_unix}}^{date} at {time}|{{timestamp}}>",
},
],
},
{
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "{{action_text}}",
},
url: "{{{action_url}}}",
style: "primary",
},
],
},
],
},
null,
2,
);
export const DefaultAPIEval = `(async function (statusCode, responseTime, responseRaw, modules) {
let statusCodeShort = Math.floor(statusCode/100);
if(statusCode == 429 || (statusCodeShort >=2 && statusCodeShort <= 3)) {
return {
status: 'UP',
latency: responseTime,
}
}
return {
status: 'DOWN',
latency: responseTime,
}
})`;
export const DefaultPingEval = `(async function (arrayOfPings) {
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
return acc + ping.latency;
}, 0);
let alive = arrayOfPings.reduce((acc, ping) => {
return acc && ping.alive;
}, true);
return {
status: alive ? 'UP' : 'DOWN',
latency: latencyTotal / arrayOfPings.length,
}
})`;
export const DefaultTCPEval = `(async function (arrayOfPings) {
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
return acc + ping.latency;
}, 0);
let alive = arrayOfPings.reduce((acc, ping) => {
return acc && ping.status === "open";
}, true);
return {
status: alive ? 'UP' : 'DOWN',
latency: latencyTotal / arrayOfPings.length,
}
})`;
export const ErrorSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="60" viewBox="0 0 120 60" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="30" cy="24" r="10"/>
<path d="M26 27h8"/>
<path d="M26 21h2"/>
<path d="M32 21h2"/>
<text x="80" y="29" text-anchor="middle" font-family="system-ui, sans-serif" font-size="14" fill="currentColor" font-weight="300">Not Found</text>
</svg>`;
+3 -2
View File
@@ -1,6 +1,7 @@
<script>
import { formatDistanceToNow, formatDistance } from "date-fns";
import { Settings, ArrowRight } from "lucide-svelte";
import Settings from "lucide-svelte/icons/settings";
import ArrowRight from "lucide-svelte/icons/arrow-right";
import * as Accordion from "$lib/components/ui/accordion";
import { l, f, fd, fdn } from "$lib/i18n/client";
import { base } from "$app/paths";
@@ -78,7 +79,7 @@
<div class="col-span-12">
<Accordion.Root bind:value={index} class="accor">
<Accordion.Item value={accordionValue}>
<Accordion.Trigger class="px-4 hover:bg-muted hover:no-underline">
<Accordion.Trigger class="rounded-md px-4 hover:bg-muted hover:no-underline">
<div class="w-full text-left hover:no-underline">
<p class="flex gap-x-2 text-xs font-semibold">
{#if incidentType == "INCIDENT"}
+3 -2
View File
@@ -2,8 +2,9 @@
import { onMount } from "svelte";
import { base } from "$app/paths";
import moment from "moment";
import { Loader, ChevronLeft, ChevronRight } from "lucide-svelte";
import Loader from "lucide-svelte/icons/loader";
import ChevronLeft from "lucide-svelte/icons/chevron-left";
import ChevronRight from "lucide-svelte/icons/chevron-right";
export let data;
let pageNo = 1;
let limit = 20;
+233 -253
View File
@@ -1,271 +1,251 @@
<script>
import { onMount } from "svelte";
import { base } from "$app/paths";
import moment from "moment";
import { Button } from "$lib/components/ui/button";
import { Plus, X, Settings, Bell, Loader, Copy, Check } from "lucide-svelte";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { onMount } from "svelte";
import { base } from "$app/paths";
import moment from "moment";
import { Button } from "$lib/components/ui/button";
import Plus from "lucide-svelte/icons/plus";
import X from "lucide-svelte/icons/x";
import Settings from "lucide-svelte/icons/settings";
import Bell from "lucide-svelte/icons/bell";
import Loader from "lucide-svelte/icons/loader";
import Copy from "lucide-svelte/icons/copy";
import Check from "lucide-svelte/icons/check";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { page } from "$app/stores";
let apiKeys = [];
let loaderLoadingAll = false;
let loaderCreateNew = false;
let newAPIKeyName = "";
let newKeyResp = {};
let showCreateModal = false;
let apiKeys = [];
let loaderLoadingAll = false;
let loaderCreateNew = false;
let newAPIKeyName = "";
let newKeyResp = {};
let showCreateModal = false;
async function loadAPIKeys() {
loaderLoadingAll = true;
try {
let apiResp = await fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
action: "getAPIKeys",
data: {}
})
});
let resp = await apiResp.json();
apiKeys = resp;
} catch (error) {
alert("Error: " + error);
} finally {
loaderLoadingAll = false;
}
}
async function loadAPIKeys() {
loaderLoadingAll = true;
try {
let apiResp = await fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
action: "getAPIKeys",
data: {}
})
});
let resp = await apiResp.json();
apiKeys = resp;
} catch (error) {
alert("Error: " + error);
} finally {
loaderLoadingAll = false;
}
}
async function createNew() {
newKeyResp = {};
loaderCreateNew = true;
try {
let apiResp = await fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
action: "createNewApiKey",
data: {
name: newAPIKeyName
}
})
});
newKeyResp = await apiResp.json();
loadAPIKeys();
showCreateModal = false;
} catch (error) {
alert("Error: " + error);
} finally {
loaderCreateNew = false;
}
}
async function createNew() {
newKeyResp = {};
loaderCreateNew = true;
try {
let apiResp = await fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
action: "createNewApiKey",
data: {
name: newAPIKeyName
}
})
});
newKeyResp = await apiResp.json();
loadAPIKeys();
showCreateModal = false;
} catch (error) {
alert("Error: " + error);
} finally {
loaderCreateNew = false;
}
}
onMount(() => {
loadAPIKeys();
});
onMount(() => {
loadAPIKeys();
});
function copyKey() {
navigator.clipboard.writeText(newKeyResp.apiKey);
}
function copyKey() {
navigator.clipboard.writeText(newKeyResp.apiKey);
}
function updateStatus(apiKey) {
apiKey.status = apiKey.status == "ACTIVE" ? "INACTIVE" : "ACTIVE";
fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
action: "updateApiKeyStatus",
data: {
id: apiKey.id,
status: apiKey.status
}
})
});
}
function updateStatus(apiKey) {
apiKey.status = apiKey.status == "ACTIVE" ? "INACTIVE" : "ACTIVE";
fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
action: "updateApiKeyStatus",
data: {
id: apiKey.id,
status: apiKey.status
}
})
});
}
</script>
<div class="mt-4">
<div class="mb-4 flex justify-between">
<div>
{#if loaderLoadingAll}
<Loader class="mt-6 h-4 w-4 animate-spin" />
{/if}
</div>
<div>
<Button
on:click={(e) => {
showCreateModal = true;
}}
>
<Plus class="mr-2 h-4 w-4" /> Create New API Key
</Button>
</div>
</div>
<div class="mb-4 flex justify-between">
<div>
{#if loaderLoadingAll}
<Loader class="mt-6 h-4 w-4 animate-spin" />
{/if}
</div>
<div>
{#if $page.data.user.role != "member"}
<Button
on:click={(e) => {
showCreateModal = true;
}}
>
<Plus class="mr-2 h-4 w-4" /> Create New API Key
</Button>
{/if}
</div>
</div>
</div>
{#if !!newKeyResp && !!newKeyResp.apiKey}
<div class="my-4 rounded-lg border border-green-700 bg-green-800 bg-opacity-20 p-4">
<p class="font-medium">
<picture class="mr-1 inline-block">
<source
srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f389/512.webp"
type="image/webp"
/>
<img
src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f389/512.gif"
alt="🎉"
width="24"
height="24"
/>
</picture>
API Key Created
</p>
<p
class="relative my-2 rounded-sm border bg-card px-4 py-2 pr-8 font-mono text-sm font-medium"
>
{newKeyResp.apiKey}
<div class="my-4 rounded-lg border border-green-700 bg-green-800 bg-opacity-20 p-4">
<p class="font-medium">
<picture class="mr-1 inline-block">
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f389/512.webp" type="image/webp" />
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f389/512.gif" alt="🎉" width="24" height="24" />
</picture>
API Key Created
</p>
<p class="relative my-2 rounded-sm border bg-card px-4 py-2 pr-8 font-mono text-sm font-medium">
{newKeyResp.apiKey}
<Button
size="icon"
variant="ghost"
class="copybtn absolute right-2 top-2 h-5 w-5 p-1"
on:click={copyKey}
>
<Check class="check-btn absolute left-0 top-0 h-4 w-4 text-green-500" />
<Copy class="copy-btn absolute left-0 top-0 h-4 w-4 " />
</Button>
</p>
<p class="text-xs text-muted-foreground">
Your new API key has been created. It will be <b class="uppercase underline"
>not be shown again</b
>, so make sure to save it.
</p>
</div>
<Button size="icon" variant="ghost" class="copybtn absolute right-2 top-2 h-5 w-5 p-1" on:click={copyKey}>
<Check class="check-btn absolute left-0 top-0 h-4 w-4 text-green-500" />
<Copy class="copy-btn absolute left-0 top-0 h-4 w-4 " />
</Button>
</p>
<p class="text-xs text-muted-foreground">
Your new API key has been created. It will be <b class="uppercase underline">not be shown again</b>, so make sure
to save it.
</p>
</div>
{/if}
<div class="flex flex-col">
<div class="-m-1.5 overflow-x-auto">
<div class="inline-block min-w-full p-1.5 align-middle">
<div class="overflow-hidden rounded-lg border dark:border-neutral-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-neutral-700">
<thead>
<tr>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
>Name</th
>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
>Key</th
>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
>Created At</th
>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-neutral-700">
{#each apiKeys as apiKey}
<tr>
<td
class="whitespace-nowrap px-6 py-4 text-sm font-medium text-gray-800 dark:text-neutral-200"
>
{apiKey.name}
</td>
<td
class="whitespace-nowrap px-6 py-4 text-xs font-semibold text-gray-800 dark:text-neutral-200"
>
{apiKey.masked_key.slice(-32)}
</td>
<td
class="whitespace-nowrap px-6 py-4 text-sm text-gray-800 dark:text-neutral-200"
>
{moment
.utc(apiKey.created_at, "YYYY-MM-DD HH:mm:ss")
.local()
.format("YYYY-MM-DD HH:mm:ss")}
</td>
<td
class="whitespace-nowrap px-6 py-4 text-xs font-semibold text-gray-800 dark:text-neutral-200"
>
<label class="inline-flex cursor-pointer items-center">
<input
type="checkbox"
value=""
class="peer sr-only"
checked={apiKey.status == "ACTIVE"}
on:change={() => {
updateStatus(apiKey);
}}
/>
<div
class="peer relative h-6 w-11 rounded-full bg-gray-200 after:absolute after:start-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rtl:peer-checked:after:-translate-x-full dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800"
></div>
</label>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
<div class="-m-1.5 overflow-x-auto">
<div class="inline-block min-w-full p-1.5 align-middle">
<div class="overflow-hidden rounded-lg border dark:border-neutral-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-neutral-700">
<thead>
<tr>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500">Name</th
>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500">Key</th
>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
>Created At</th
>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-neutral-700">
{#each apiKeys as apiKey}
<tr>
<td class="whitespace-nowrap px-6 py-4 text-sm font-medium text-gray-800 dark:text-neutral-200">
{apiKey.name}
</td>
<td class="whitespace-nowrap px-6 py-4 text-xs font-semibold text-gray-800 dark:text-neutral-200">
{apiKey.masked_key.slice(-32)}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-800 dark:text-neutral-200">
{moment.utc(apiKey.created_at, "YYYY-MM-DD HH:mm:ss").local().format("YYYY-MM-DD HH:mm:ss")}
</td>
<td class="whitespace-nowrap px-6 py-4 text-xs font-semibold text-gray-800 dark:text-neutral-200">
<label class="inline-flex cursor-pointer items-center">
<input
type="checkbox"
disabled={$page.data.user.role == "member"}
value=""
class="peer sr-only"
checked={apiKey.status == "ACTIVE"}
on:change={() => {
updateStatus(apiKey);
}}
/>
<div
class="peer relative h-6 w-11 rounded-full bg-gray-200 after:absolute after:start-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 peer-disabled:opacity-55 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800 rtl:peer-checked:after:-translate-x-full"
></div>
</label>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
</div>
{#if showCreateModal}
<div
class="moldal-container fixed left-0 top-0 z-30 h-screen w-full bg-card bg-opacity-30 backdrop-blur-sm"
>
<div
class="absolute left-1/2 top-1/2 h-fit w-full max-w-xl -translate-x-1/2 -translate-y-1/2 rounded-md border bg-background shadow-lg backdrop-blur-lg"
>
<Button
variant="ghost"
on:click={() => {
showCreateModal = false;
}}
class="absolute right-2 top-2 z-40 h-6 w-6 rounded-full border bg-background p-1"
>
<X class="h-4 w-4 text-muted-foreground" />
</Button>
<div class="content px-4 py-4">
<h2 class="text-lg font-semibold">Create a new API Key</h2>
<p class="text-xs text-muted-foreground">
API keys are used to authenticate your requests to the API. They are unique to
your account and should be kept secret.
</p>
<hr class="my-4" />
<form on:submit|preventDefault={createNew}>
<div class="flex flex-col gap-4">
<div>
<Label for="newAPIKeyName">Name</Label>
<Input
bind:value={newAPIKeyName}
class="mt-2"
type="text"
id="newAPIKeyName"
placeholder="eg. My API Key"
/>
</div>
</div>
<div class="mt-4 flex justify-end">
<Button variant="secondary" type="submit" disabled={loaderCreateNew}>
Create
{#if loaderCreateNew}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</form>
</div>
</div>
</div>
<div class="moldal-container fixed left-0 top-0 z-30 h-screen w-full bg-card bg-opacity-30 backdrop-blur-sm">
<div
class="absolute left-1/2 top-1/2 h-fit w-full max-w-xl -translate-x-1/2 -translate-y-1/2 rounded-md border bg-background shadow-lg backdrop-blur-lg"
>
<Button
variant="ghost"
on:click={() => {
showCreateModal = false;
}}
class="absolute right-2 top-2 z-40 h-6 w-6 rounded-full border bg-background p-1"
>
<X class="h-4 w-4 text-muted-foreground" />
</Button>
<div class="content px-4 py-4">
<h2 class="text-lg font-semibold">Create a new API Key</h2>
<p class="text-xs text-muted-foreground">
API keys are used to authenticate your requests to the API. They are unique to your account and should be kept
secret.
</p>
<hr class="my-4" />
<form on:submit|preventDefault={createNew}>
<div class="flex flex-col gap-4">
<div>
<Label for="newAPIKeyName">Name</Label>
<Input
bind:value={newAPIKeyName}
class="mt-2"
type="text"
id="newAPIKeyName"
required
placeholder="eg. My API Key"
/>
</div>
</div>
<div class="mt-4 flex justify-end">
<Button variant="secondary" type="submit" disabled={loaderCreateNew}>
Create
{#if loaderCreateNew}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</form>
</div>
</div>
</div>
{/if}
+43 -12
View File
@@ -9,7 +9,11 @@
import locales from "$lib/locales/locales.json?raw";
import GMI from "$lib/components/gmi.svelte";
import { Loader, X, Plus, Info, Play } from "lucide-svelte";
import Loader from "lucide-svelte/icons/loader";
import X from "lucide-svelte/icons/x";
import Plus from "lucide-svelte/icons/plus";
import Info from "lucide-svelte/icons/info";
import Play from "lucide-svelte/icons/play";
import { Tooltip } from "bits-ui";
export let data;
@@ -87,8 +91,9 @@
}
});
}
let heroError = "";
async function formSubmitHero() {
heroError = "";
formStateHero = "loading";
let resp = await storeSiteData({
hero: JSON.stringify(hero)
@@ -97,7 +102,7 @@
let data = await resp.json();
formStateHero = "idle";
if (data.error) {
alert(data.error);
heroError = data.error;
return;
}
}
@@ -117,7 +122,10 @@
return;
}
}
let categoriesErrorMessage = "";
async function formSubmitCategories() {
categoriesErrorMessage = "";
formStateCategories = "loading";
let resp = await storeSiteData({
categories: JSON.stringify(categories)
@@ -126,11 +134,14 @@
let data = await resp.json();
formStateCategories = "idle";
if (data.error) {
alert(data.error);
categoriesErrorMessage = data.error;
return;
}
}
let footerErrorMessage = "";
async function formSubmitFooter() {
footerErrorMessage = "";
formStateFooter = "loading";
let resp = await storeSiteData({
footerHTML: footerHTML
@@ -139,11 +150,14 @@
let data = await resp.json();
formStateFooter = "idle";
if (data.error) {
alert(data.error);
footerErrorMessage = data.error;
return;
}
}
let incidentErrorMessage = "";
async function formSubmitIncident() {
incidentErrorMessage = "";
formStateIncident = "loading";
let resp = await storeSiteData({
homeIncidentCount: homeIncidentCount,
@@ -154,7 +168,7 @@
let data = await resp.json();
formStateIncident = "idle";
if (data.error) {
alert(data.error);
incidentErrorMessage = data.error;
return;
}
}
@@ -220,7 +234,9 @@
i18n.defaultLocale = e.value;
}
let i18nErrorMessage = "";
async function formSubmiti18n() {
i18nErrorMessage = "";
formStatei18n = "loading";
let resp = await storeSiteData({
i18n: JSON.stringify(i18n),
@@ -230,7 +246,7 @@
let data = await resp.json();
formStatei18n = "idle";
if (data.error) {
alert(data.error);
i18nErrorMessage = data.error;
return;
}
}
@@ -289,7 +305,10 @@
/>
</div>
</div>
<div class="flex w-full justify-end">
<div class="flex w-full justify-end gap-x-2">
{#if !!heroError}
<div class="py-2 text-sm font-medium text-destructive">{heroError}</div>
{/if}
<Button type="submit" disabled={formStateHero === "loading"}>
Save
{#if formStateHero === "loading"}
@@ -362,6 +381,9 @@
Translates to "Show {homeIncidentCount} incidents that have started in the last {homeIncidentStartTimeWithin}
days in the past or going to start in {homeIncidentStartTimeWithin} days in the future"
</p>
{#if !!incidentErrorMessage}
<p class="text-sm font-medium text-destructive">{incidentErrorMessage}</p>
{/if}
</form>
</Card.Content>
</Card.Root>
@@ -470,7 +492,7 @@
</div>
<div class="flex w-full justify-end" style="margin-top:32px">
<p class="px-4 pt-1">
<span class="text-xs text-red-500"> {navErrorMessage} </span>
<span class="text-sm font-medium text-destructive"> {navErrorMessage} </span>
</p>
<Button type="submit" disabled={formStateHero === "loading"}>
Save
@@ -547,7 +569,10 @@
Allow users to switch timezones
</label>
</p>
<div class="flex w-full justify-end">
<div class="flex w-full justify-end gap-x-2">
{#if !!i18nErrorMessage}
<div class="py-2 text-sm font-medium text-destructive">{i18nErrorMessage}</div>
{/if}
<Button type="submit" disabled={formStatei18n === "loading"}>
Save
{#if formStatei18n === "loading"}
@@ -607,7 +632,10 @@
<Plus class="h-4 w-4 " />
</Button>
</div>
<div class="flex w-full justify-end">
<div class="flex w-full justify-end gap-x-2">
{#if !!categoriesErrorMessage}
<div class="py-2 text-sm font-medium text-destructive">{categoriesErrorMessage}</div>
{/if}
<Button type="submit" disabled={formStateCategories === "loading"}>
Save
{#if formStateCategories === "loading"}
@@ -636,7 +664,10 @@
</div>
</div>
<div class="flex w-full justify-end">
<div class="flex w-full justify-end gap-x-2">
{#if !!footerErrorMessage}
<div class="py-2 text-sm font-medium text-destructive">{footerErrorMessage}</div>
{/if}
<Button type="submit" disabled={formStateFooter === "loading"}>
Save
{#if formStateFooter === "loading"}
+74 -21
View File
@@ -1,15 +1,24 @@
<script>
import { Button } from "$lib/components/ui/button";
import { Plus, X, Loader, Clipboard, Check } from "lucide-svelte";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { clickOutsideAction, slide } from "svelte-legos";
import { ChevronRight, Trash } from "lucide-svelte";
import Plus from "lucide-svelte/icons/plus";
import X from "lucide-svelte/icons/x";
import Loader from "lucide-svelte/icons/loader";
import Clipboard from "lucide-svelte/icons/clipboard";
import Check from "lucide-svelte/icons/check";
import ChevronRight from "lucide-svelte/icons/chevron-right";
import Trash from "lucide-svelte/icons/trash";
import { base } from "$app/paths";
import * as Select from "$lib/components/ui/select";
import { createEventDispatcher } from "svelte";
import GMI from "$lib/components/gmi.svelte";
import { page } from "$app/stores";
import CodeMirror from "svelte-codemirror-editor";
import { javascript } from "@codemirror/lang-javascript";
import { githubLight, githubDark } from "@uiw/codemirror-theme-github";
import { mode } from "mode-watcher";
import {
allRecordTypes,
ValidateIpAddress,
@@ -100,8 +109,8 @@
return false;
}
try {
// let evalResp = await eval(newMonitor.apiConfig.eval + `(200, 1000, "e30=")`);
new Function(ev);
return true; // The code is valid
} catch (error) {
invalidFormMessage = error.message + " in eval.";
@@ -718,6 +727,18 @@
></textarea>
</div>
{/if}
<div class="col-span-6">
<label class="cursor-pointer">
<input
type="checkbox"
on:change={(e) => {
newMonitor.apiConfig.allowSelfSignedCert = e.target.checked;
}}
checked={newMonitor.apiConfig.allowSelfSignedCert}
/>
<span class="ml-2 text-sm">Allow Self Signed Certificate</span>
</label>
</div>
<div class="col-span-6">
<Label for="eval">Eval</Label>
<p class="my-1 text-xs text-muted-foreground">
@@ -728,12 +749,23 @@
href="https://kener.ing/docs/monitors-api#eval">Read the docs</a
> to learn
</p>
<textarea
bind:value={newMonitor.apiConfig.eval}
id="eval"
class="h-96 w-full rounded-sm border p-2"
placeholder="Leave blank or write a custom eval function"
></textarea>
<div class="overflow-hidden rounded-md">
<CodeMirror
bind:value={newMonitor.apiConfig.eval}
lang={javascript()}
theme={$mode == "dark" ? githubDark : githubLight}
styles={{
"&": {
width: "100%",
maxWidth: "100%",
height: "18rem",
border: "1px solid hsl(var(--border) / var(--tw-border-opacity))",
borderRadius: "0.375rem"
}
}}
/>
</div>
</div>
</div>
{:else if newMonitor.monitor_type == "PING"}
@@ -823,12 +855,23 @@
href="https://kener.ing/docs/monitors-ping#eval">Read the docs</a
> to learn
</p>
<textarea
bind:value={newMonitor.pingConfig.pingEval}
id="pingEval"
class="h-96 w-full rounded-sm border p-2"
placeholder="Leave blank or write a custom eval function"
></textarea>
<div class="overflow-hidden rounded-md">
<CodeMirror
bind:value={newMonitor.pingConfig.pingEval}
lang={javascript()}
theme={$mode == "dark" ? githubDark : githubLight}
styles={{
"&": {
width: "100%",
maxWidth: "100%",
height: "18rem",
border: "1px solid hsl(var(--border) / var(--tw-border-opacity))",
borderRadius: "0.375rem"
}
}}
/>
</div>
</div>
</div>
</div>
@@ -914,12 +957,22 @@
href="https://kener.ing/docs/monitors-tcp#eval">Read the docs</a
> to learn
</p>
<textarea
bind:value={newMonitor.tcpConfig.tcpEval}
id="tcpEval"
class="h-96 w-full rounded-sm border p-2"
placeholder="Leave blank or write a custom eval function"
></textarea>
<div class="overflow-hidden rounded-md">
<CodeMirror
bind:value={newMonitor.tcpConfig.tcpEval}
lang={javascript()}
theme={$mode == "dark" ? githubDark : githubLight}
styles={{
"&": {
width: "100%",
maxWidth: "100%",
height: "22rem",
border: "1px solid hsl(var(--border) / var(--tw-border-opacity))",
borderRadius: "0.375rem"
}
}}
/>
</div>
</div>
</div>
</div>
+77 -36
View File
@@ -1,6 +1,13 @@
<script>
import { Button } from "$lib/components/ui/button";
import { Plus, X, Settings, Bell, Loader, ArrowDownUp, Grip, ExternalLink } from "lucide-svelte";
import Plus from "lucide-svelte/icons/plus";
import X from "lucide-svelte/icons/x";
import Settings from "lucide-svelte/icons/settings";
import Bell from "lucide-svelte/icons/bell";
import Loader from "lucide-svelte/icons/loader";
import ArrowDownUp from "lucide-svelte/icons/arrow-down-up";
import Grip from "lucide-svelte/icons/grip";
import ExternalLink from "lucide-svelte/icons/external-link";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { base } from "$app/paths";
@@ -13,6 +20,8 @@
import { dndzone } from "svelte-dnd-action";
import { flip } from "svelte/animate";
import GMI from "$lib/components/gmi.svelte";
import { page } from "$app/stores";
import { DefaultAPIEval, DefaultTCPEval, DefaultPingEval } from "$lib/anywhere.js";
export let categories = [];
export let colorDown = "#777";
@@ -70,16 +79,17 @@
headers: [],
body: "",
timeout: 10000,
eval: "",
hideURLForGet: "NO"
eval: DefaultAPIEval,
hideURLForGet: "NO",
allowSelfSignedCert: false
},
tcpConfig: {
hosts: [], //{timeout: 1000, host: "", type:""}
tcpEval: ""
tcpEval: DefaultTCPEval
},
pingConfig: {
hosts: [], //{timeout: 1000, host: "", count: "", type:""}
pingEval: ""
pingEval: DefaultPingEval
},
dnsConfig: {
host: "",
@@ -242,7 +252,9 @@
}
};
let saveTriggerError = "";
async function saveTriggers() {
saveTriggerError = "";
let data = {
id: currentAlertMonitor.id,
down_trigger: JSON.stringify(monitorTriggers.down_trigger),
@@ -258,12 +270,18 @@
},
body: JSON.stringify({ action: "updateMonitorTriggers", data })
});
let resp = await apiResp.json();
if (resp.error) {
saveTriggerError = resp.error;
} else {
shareMenusToggle = false;
loadData();
}
} catch (error) {
alert("Error: " + error);
saveTriggerError = "Error while saving triggers";
} finally {
formState = "idle";
loadData();
shareMenusToggle = false;
}
}
let currentAlertMonitor;
@@ -278,15 +296,27 @@
shareMenusToggle = true;
}
const flipDurationMs = 200;
let orderErrorMessage = "";
function handleSort(e) {
dropTargetStyle = {
border: "1px solid transparent"
};
monitors = e.detail.items;
monitorSort = monitors.map((m) => m.id);
storeSiteData({
monitorSort: JSON.stringify(monitorSort)
});
})
.then(async (resp) => {
let data = await resp.json();
if (data.error) {
orderErrorMessage = data.error;
}
})
.catch((error) => {
orderErrorMessage = "Error while saving order";
});
}
let dropTargetStyle;
let draggableMenu = false;
@@ -360,6 +390,9 @@
</div>
{/each}
</div>
{#if !!orderErrorMessage}
<p class="py-2 text-sm font-medium text-destructive">{orderErrorMessage}</p>
{/if}
</div>
</div>
</div>
@@ -423,17 +456,19 @@
{/if}
</div>
</div>
<div>
{#if status == "ACTIVE"}
<Button size="icon" variant="secondary" on:click={() => (draggableMenu = !draggableMenu)}>
<ArrowDownUp class=" " />
{#if $page.data.user.role != "member"}
<div>
{#if status == "ACTIVE"}
<Button size="icon" variant="secondary" on:click={() => (draggableMenu = !draggableMenu)}>
<ArrowDownUp class=" " />
</Button>
{/if}
<Button on:click={showAddMonitorSheet}>
<Plus class="mr-2 inline h-6 w-6" />
Add Monitor
</Button>
{/if}
<Button on:click={showAddMonitorSheet}>
<Plus class="mr-2 inline h-6 w-6" />
Add Monitor
</Button>
</div>
</div>
{/if}
</div>
<div class="mt-4">
@@ -452,7 +487,7 @@
<div class="absolute right-2 top-0.5 flex gap-x-1">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="secondary" class="h-8 p-2 text-xs" on:click={() => testMonitor(i)}>TEST</Button>
<Button variant="secondary" class="h-8 p-2 text-xs" on:click={() => testMonitor(i)}>Test Monitor</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content class="max-w-md">
<DropdownMenu.Group>
@@ -482,22 +517,25 @@
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Button
variant="secondary"
class="h-8 w-8 p-2 {monitor.down_trigger?.active || monitor.degraded_trigger?.active
? 'text-yellow-500'
: ''}"
on:click={() => openAlertMenu(monitor)}
>
<Bell class="inline h-4 w-4" />
</Button>
{#if $page.data.user.role != "member"}
<Button
variant="secondary"
class="h-8 w-8 p-2 {monitor.down_trigger?.active || monitor.degraded_trigger?.active
? 'text-yellow-500'
: ''}"
on:click={() => openAlertMenu(monitor)}
>
<Bell class="inline h-4 w-4" />
</Button>
{/if}
<Button variant="secondary" class="h-8 w-8 p-2" rel="external" href="{base}/?monitor={monitor.tag}">
<ExternalLink class="inline h-4 w-4" />
</Button>
<Button variant="secondary" class="h-8 w-8 p-2" href="#{monitor.tag}">
<Settings class="inline h-4 w-4" />
</Button>
{#if $page.data.user.role != "member"}
<Button variant="secondary" class="h-8 w-8 p-2" href="#{monitor.tag}">
<Settings class="inline h-4 w-4" />
</Button>
{/if}
</div>
</Card.Header>
<Card.Content>
@@ -677,9 +715,12 @@
<hr class="my-4" />
{/each}
<div class="flex justify-end">
<Button class="w-full" on:click={saveTriggers} disabled={formState === "loading"}>
Save
<div class="flex justify-end gap-x-2">
{#if !!saveTriggerError}
<div class="py-2 text-sm font-medium text-destructive">{saveTriggerError}</div>
{/if}
<Button class="" on:click={saveTriggers} disabled={formState === "loading"}>
Save Alerts
{#if formState === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
+197 -205
View File
@@ -1,221 +1,213 @@
<script>
import * as Card from "$lib/components/ui/card";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Plus, X } from "lucide-svelte";
import autoAnimate from "@formkit/auto-animate";
import { siteDataExtractFromDb, storeSiteData } from "$lib/clientTools.js";
import { base } from "$app/paths";
import { Loader, Info } from "lucide-svelte";
import { Tooltip } from "bits-ui";
import * as Card from "$lib/components/ui/card";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import autoAnimate from "@formkit/auto-animate";
import { siteDataExtractFromDb, storeSiteData } from "$lib/clientTools.js";
import { base } from "$app/paths";
import Plus from "lucide-svelte/icons/plus";
import X from "lucide-svelte/icons/x";
import Loader from "lucide-svelte/icons/loader";
import Info from "lucide-svelte/icons/info";
import { Tooltip } from "bits-ui";
export let data;
export let data;
let metaTags = [];
function addNewRow() {
metaTags = [
...metaTags,
{
key: "",
value: ""
}
];
}
let analyticsSupported = [
{
id: "",
type: "GA",
name: "Google Analytics",
script: "https://unpkg.com/@analytics/google-analytics@1.0.7/dist/@analytics/google-analytics.min.js"
},
{
id: "",
type: "AMPLITUDE",
name: "Amplitude",
script: "https://unpkg.com/@analytics/amplitude@0.1.3/dist/@analytics/amplitude.min.js"
},
{
id: "",
type: "MIXPANEL",
name: "MixPanel",
script: "https://unpkg.com/@analytics/mixpanel@0.4.0/dist/@analytics/mixpanel.min.js"
}
];
function removeRow(i) {
metaTags = metaTags.filter((_, index) => index !== i);
}
let metaTags = [];
function addNewRow() {
metaTags = [
...metaTags,
{
key: "",
value: ""
}
];
}
let analyticsSupported = [
{
id: "",
type: "GA",
name: "Google Analytics",
script: "https://unpkg.com/@analytics/google-analytics@1.0.7/dist/@analytics/google-analytics.min.js"
},
{
id: "",
type: "AMPLITUDE",
name: "Amplitude",
script: "https://unpkg.com/@analytics/amplitude@0.1.3/dist/@analytics/amplitude.min.js"
},
{
id: "",
type: "MIXPANEL",
name: "MixPanel",
script: "https://unpkg.com/@analytics/mixpanel@0.4.0/dist/@analytics/mixpanel.min.js"
}
];
function removeRow(i) {
metaTags = metaTags.filter((_, index) => index !== i);
}
metaTags = siteDataExtractFromDb(data.siteData, {
metaTags: JSON.stringify(metaTags)
}).metaTags;
metaTags = siteDataExtractFromDb(data.siteData, {
metaTags: JSON.stringify(metaTags)
}).metaTags;
let analytics = siteDataExtractFromDb(data.siteData, {
analytics: []
}).analytics;
let analytics = siteDataExtractFromDb(data.siteData, {
analytics: []
}).analytics;
//merge analyticsSupported with analytics
analytics = analyticsSupported.map((supported) => {
let found = analytics.find((a) => a.type === supported.type);
if (found) {
return found;
}
return supported;
});
//merge analyticsSupported with analytics
analytics = analyticsSupported.map((supported) => {
let found = analytics.find((a) => a.type === supported.type);
if (found) {
return found;
}
return supported;
});
let formState = "idle";
let formStateAnalytics = "idle";
async function formSubmit() {
formState = "loading";
let resp = await storeSiteData({
metaTags: JSON.stringify(metaTags)
});
//print data
let data = await resp.json();
formState = "idle";
if (data.error) {
alert(data.error);
return;
}
}
let formState = "idle";
let formStateAnalytics = "idle";
async function formSubmitAnalytics() {
formStateAnalytics = "loading";
let resp = await storeSiteData({
analytics: JSON.stringify(analytics)
});
//print data
let data = await resp.json();
formStateAnalytics = "idle";
if (data.error) {
alert(data.error);
return;
}
}
let metaError = "";
async function formSubmit() {
metaError = "";
formState = "loading";
let resp = await storeSiteData({
metaTags: JSON.stringify(metaTags)
});
//print data
let data = await resp.json();
formState = "idle";
if (data.error) {
metaError = data.error;
return;
}
}
let seoError = "";
async function formSubmitAnalytics() {
seoError = "";
formStateAnalytics = "loading";
let resp = await storeSiteData({
analytics: JSON.stringify(analytics)
});
//print data
let data = await resp.json();
formStateAnalytics = "idle";
if (data.error) {
seoError = data.error;
return;
}
}
</script>
<Card.Root class="mt-4">
<Card.Header class="border-b">
<Card.Title>Analytics</Card.Title>
<Card.Description>
Add your analytics ID/Key here. You can add multiple analytics providers.
</Card.Description>
</Card.Header>
<Card.Content>
<form class="mx-auto mt-4 space-y-4" on:submit|preventDefault={formSubmitAnalytics}>
<div class="grid grid-cols-2 gap-2">
{#each analytics as analytic}
<div class="col-span-1">
<Label for={analytic.type}>
{analytic.name}
<Tooltip.Root openDelay={100}>
<Tooltip.Trigger class="">
<Info class="inline-block h-4 w-4 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<div
class=" flex items-center justify-center rounded border bg-gray-800 p-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
>
Add your {analytic.name} ID/Key here.
</div>
</Tooltip.Content>
</Tooltip.Root>
</Label>
<Input
bind:value={analytic.id}
class="mt-2"
type="text"
id={analytic.type}
placeholder="{analytic.type} ID/Key"
/>
</div>
{/each}
</div>
<Card.Header class="border-b">
<Card.Title>Analytics</Card.Title>
<Card.Description>Add your analytics ID/Key here. You can add multiple analytics providers.</Card.Description>
</Card.Header>
<Card.Content>
<form class="mx-auto mt-4 space-y-4" on:submit|preventDefault={formSubmitAnalytics}>
<div class="grid grid-cols-2 gap-2">
{#each analytics as analytic}
<div class="col-span-1">
<Label for={analytic.type}>
{analytic.name}
<Tooltip.Root openDelay={100}>
<Tooltip.Trigger class="">
<Info class="inline-block h-4 w-4 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<div
class=" flex items-center justify-center rounded border bg-gray-800 p-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
>
Add your {analytic.name} ID/Key here.
</div>
</Tooltip.Content>
</Tooltip.Root>
</Label>
<Input
bind:value={analytic.id}
class="mt-2"
type="text"
id={analytic.type}
placeholder="{analytic.type} ID/Key"
/>
</div>
{/each}
</div>
<div class="flex w-full justify-end">
<Button type="submit" disabled={formStateAnalytics === "loading"}>
Save
{#if formStateAnalytics === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</form>
</Card.Content>
<div class="flex w-full justify-end gap-x-2">
{#if !!seoError}
<div class="py-2 text-sm font-medium text-destructive">{seoError}</div>
{/if}
<Button type="submit" disabled={formStateAnalytics === "loading"}>
Save
{#if formStateAnalytics === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</form>
</Card.Content>
</Card.Root>
<Card.Root class="mt-4">
<Card.Header class="border-b">
<Card.Title>Search Engine Optimization</Card.Title>
<Card.Description>
Add your meta tags here. You can add multiple meta tags.
</Card.Description>
</Card.Header>
<Card.Content>
<form class="mx-auto mt-4 space-y-4" use:autoAnimate on:submit|preventDefault={formSubmit}>
{#each metaTags as metaTag, i}
<div class="flex w-full flex-row justify-between gap-2">
<div class="grid grid-cols-12 gap-x-2">
<div class="col-span-4">
<Label for="key">
Meta Name
<Tooltip.Root openDelay={100}>
<Tooltip.Trigger class="">
<Info class="inline-block h-4 w-4 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content class="max-w-md">
<div
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
>
&#x3C;meta name=&#x22;{metaTag.key}&#x22; content=&#x22;{metaTag.value}&#x22;&#x3E;
</div>
</Tooltip.Content>
</Tooltip.Root>
</Label>
<Input
bind:value={metaTag.key}
class="mt-2"
type="text"
id="key"
placeholder="eg. kener"
/>
</div>
<div class="col-span-7">
<Label for="value">Content</Label>
<Input
bind:value={metaTag.value}
class="mt-2"
type="text"
id="value"
placeholder="eg. kener"
/>
</div>
<div class="col-span-1">
<Button variant="ghost" class="mt-8" on:click={() => removeRow(i)}>
<X class="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/each}
<div class="relative">
<hr class="border-1 border-border-input relative top-4 h-px border-dashed" />
<Card.Header class="border-b">
<Card.Title>Search Engine Optimization</Card.Title>
<Card.Description>Add your meta tags here. You can add multiple meta tags.</Card.Description>
</Card.Header>
<Card.Content>
<form class="mx-auto mt-4 space-y-4" use:autoAnimate on:submit|preventDefault={formSubmit}>
{#each metaTags as metaTag, i}
<div class="flex w-full flex-row justify-between gap-2">
<div class="grid grid-cols-12 gap-x-2">
<div class="col-span-4">
<Label for="key">
Meta Name
<Tooltip.Root openDelay={100}>
<Tooltip.Trigger class="">
<Info class="inline-block h-4 w-4 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content class="max-w-md">
<div
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
>
&#x3C;meta name=&#x22;{metaTag.key}&#x22; content=&#x22;{metaTag.value}&#x22;&#x3E;
</div>
</Tooltip.Content>
</Tooltip.Root>
</Label>
<Input bind:value={metaTag.key} class="mt-2" type="text" id="key" placeholder="eg. kener" />
</div>
<div class="col-span-7">
<Label for="value">Content</Label>
<Input bind:value={metaTag.value} class="mt-2" type="text" id="value" placeholder="eg. kener" />
</div>
<div class="col-span-1">
<Button variant="ghost" class="mt-8" on:click={() => removeRow(i)}>
<X class="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/each}
<div class="relative">
<hr class="border-1 border-border-input relative top-4 h-px border-dashed" />
<Button
on:click={addNewRow}
variant="secondary"
class="absolute left-1/2 h-8 w-8 -translate-x-1/2 p-0 "
>
<Plus class="h-4 w-4 " />
</Button>
</div>
<div class="flex w-full justify-end" style="margin-top:32px">
<Button type="submit" disabled={formState === "loading"}>
Save
{#if formState === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</form>
</Card.Content>
<Button on:click={addNewRow} variant="secondary" class="absolute left-1/2 h-8 w-8 -translate-x-1/2 p-0 ">
<Plus class="h-4 w-4 " />
</Button>
</div>
<div class="flex w-full justify-end gap-x-2" style="margin-top:32px">
{#if !!metaError}
<div class="py-2 text-sm font-medium text-destructive">{metaError}</div>
{/if}
<Button type="submit" disabled={formState === "loading"}>
Save
{#if formState === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</form>
</Card.Content>
</Card.Root>
+6 -6
View File
@@ -6,7 +6,9 @@
import { siteDataExtractFromDb, storeSiteData } from "$lib/clientTools.js";
import { tooltipAction } from "svelte-legos";
import { base } from "$app/paths";
import { Loader, Info, X } from "lucide-svelte";
import Loader from "lucide-svelte/icons/loader";
import Info from "lucide-svelte/icons/info";
import X from "lucide-svelte/icons/x";
import * as Alert from "$lib/components/ui/alert";
import { Tooltip } from "bits-ui";
import * as Select from "$lib/components/ui/select";
@@ -431,11 +433,9 @@
{/if}
</div>
</div>
<div class="flex w-full justify-end">
<p class="px-4 pt-3 text-sm">
<span class="text-red-500">
{formErrorMessage}
</span>
<div class="flex w-full justify-end gap-x-2">
<p class="py-2 text-sm font-medium text-destructive">
{formErrorMessage}
</p>
<Button type="submit" disabled={formState === "loading"}>
Save
+11 -4
View File
@@ -3,11 +3,13 @@
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Plus, X, Info } from "lucide-svelte";
import Plus from "lucide-svelte/icons/plus";
import X from "lucide-svelte/icons/x";
import Info from "lucide-svelte/icons/info";
import Loader from "lucide-svelte/icons/loader";
import autoAnimate from "@formkit/auto-animate";
import { siteDataExtractFromDb, storeSiteData } from "$lib/clientTools.js";
import { base } from "$app/paths";
import { Loader } from "lucide-svelte";
import * as RadioGroup from "$lib/components/ui/radio-group";
import { Tooltip } from "bits-ui";
import ColorPicker from "svelte-awesome-color-picker";
@@ -15,6 +17,7 @@
export let data;
let formState = "idle";
let themeError = "";
let themeData = {
pattern: "none",
@@ -49,6 +52,7 @@
async function formSubmit() {
formState = "loading";
themeError = "";
let themeDataAPI = { ...themeData };
themeDataAPI.colors = JSON.stringify(themeDataAPI.colorsJ);
themeDataAPI.font = JSON.stringify(themeDataAPI.fontJ);
@@ -59,7 +63,7 @@
let data = await resp.json();
formState = "idle";
if (data.error) {
alert(data.error);
themeError = data.error;
return;
}
}
@@ -345,7 +349,10 @@
</div>
</div>
<hr />
<div class="flex w-full justify-end">
<div class="flex w-full justify-end gap-x-2">
{#if !!themeError}
<div class="py-2 text-sm font-medium text-destructive">{themeError}</div>
{/if}
<Button type="submit" disabled={formState === "loading"}>
Save
{#if formState === "loading"}
+163 -23
View File
@@ -1,6 +1,12 @@
<script>
import { Button } from "$lib/components/ui/button";
import { Plus, X, Loader, Settings, Check, ChevronRight } from "lucide-svelte";
import { page } from "$app/stores";
import Plus from "lucide-svelte/icons/plus";
import X from "lucide-svelte/icons/x";
import Loader from "lucide-svelte/icons/loader";
import Settings from "lucide-svelte/icons/settings";
import Check from "lucide-svelte/icons/check";
import ChevronRight from "lucide-svelte/icons/chevron-right";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { base } from "$app/paths";
@@ -9,8 +15,14 @@
import { createEventDispatcher } from "svelte";
import { onMount } from "svelte";
import { IsValidURL } from "$lib/clientTools.js";
import { DiscordJSONTemplate, WebhookJSONTemplate, SlackJSONTemplate } from "$lib/anywhere.js";
import { clickOutsideAction, slide } from "svelte-legos";
import * as Card from "$lib/components/ui/card";
import CodeMirror from "svelte-codemirror-editor";
import { json } from "@codemirror/lang-json";
import { githubLight, githubDark } from "@uiw/codemirror-theme-github";
import { mode } from "mode-watcher";
let status = "ACTIVE";
let formState = "idle";
let showAddTrigger = false;
@@ -29,8 +41,12 @@
headers: [],
to: "",
from: data.fromEmail,
webhook_body: "",
webhook_body: WebhookJSONTemplate,
has_webhook_body: false,
has_discord_body: false,
discord_body: DiscordJSONTemplate,
has_slack_body: false,
slack_body: SlackJSONTemplate,
email_type: data.preferredModeEmail,
smtp_host: data.smtp?.smtp_host ? data.smtp.smtp_host : "",
smtp_port: data.smtp?.smtp_port ? data.smtp.smtp_port : "",
@@ -52,8 +68,12 @@
headers: [],
to: "",
from: data.fromEmail,
webhook_body: "",
webhook_body: WebhookJSONTemplate,
has_webhook_body: false,
has_discord_body: false,
discord_body: DiscordJSONTemplate,
has_slack_body: false,
slack_body: SlackJSONTemplate,
email_type: data.preferredModeEmail,
smtp_host: data.smtp?.smtp_host ? data.smtp.smtp_host : "",
smtp_port: data.smtp?.smtp_port ? data.smtp.smtp_port : "",
@@ -91,13 +111,35 @@
async function addNewTrigger() {
invalidFormMessage = "";
formState = "loading";
// formState = "loading";
//newTrigger.trigger_type present not empty
if (newTrigger.trigger_type == "") {
invalidFormMessage = "Trigger Type is required";
formState = "idle";
return;
}
if (newTrigger.trigger_type == "discord") {
if (newTrigger.trigger_meta.has_discord_body) {
try {
JSON.parse(newTrigger.trigger_meta.discord_body);
} catch (e) {
invalidFormMessage = "Invalid JSON in Discord Body";
formState = "idle";
return;
}
}
}
if (newTrigger.trigger_type == "slack") {
if (newTrigger.trigger_meta.has_slack_body) {
try {
JSON.parse(newTrigger.trigger_meta.slack_body);
} catch (e) {
invalidFormMessage = "Invalid JSON in Slack Body";
formState = "idle";
return;
}
}
}
if (newTrigger.trigger_type == "email") {
newTrigger.trigger_meta.url = "";
@@ -346,10 +388,12 @@
<Loader class="ml-2 mt-2 inline h-6 w-6 animate-spin" />
{/if}
</div>
<Button on:click={addNewTriggerFn}>
<Plus class="mr-2 inline h-6 w-6" />
New Trigger
</Button>
{#if $page.data.user.role != "member"}
<Button on:click={addNewTriggerFn}>
<Plus class="mr-2 inline h-6 w-6" />
New Trigger
</Button>
{/if}
</div>
<div class="mt-4">
@@ -382,12 +426,13 @@
{#if trigger.testLoaders == "success"}
<Check class="mr-1 inline h-3 w-3 text-green-500 " />
{/if}
<span class="h-4 text-xs font-medium"> TEST </span>
</Button>
<Button variant="secondary" class=" h-8 w-8 p-2" href={"#" + trigger.id}>
<Settings class="inline h-4 w-4" />
<span class="h-4 text-xs font-medium"> Test Trigger </span>
</Button>
{#if $page.data.user.role != "member"}
<Button variant="secondary" class=" h-8 w-8 p-2" href={"#" + trigger.id}>
<Settings class="inline h-4 w-4" />
</Button>
{/if}
</div>
</Card.Header>
</Card.Root>
@@ -551,7 +596,7 @@
</Button>
</div>
<div>
<label>
<label class="text-sm font-medium">
<input
on:change={(e) => {
newTrigger.trigger_meta.has_webhook_body = e.target.checked;
@@ -567,21 +612,116 @@
set. The default is JSON. There are <a
target="_blank"
class="text-blue-500"
href="https://kener.ing/docs/triggers#webhook-body">variables</a
href="https://kener.ing/docs/triggers#triggers-trigger-variables">mustache variables</a
>
that you can use for the webhook body. To use a variable wrap it like
<code>$&#123;variable&#125;</code>
<code>&#123;&#123;variable&#125;&#125;</code>. There are some examples
<a target="_blank" class="text-blue-500" href="https://kener.ing/docs/triggers#webhook-examples"
>here</a
>
</p>
<div class="w-full">
<textarea
<div class="overflow-hidden rounded-md">
<CodeMirror
bind:value={newTrigger.trigger_meta.webhook_body}
class="mt-2 h-[500px] w-full rounded-sm border p-2"
placeholder={placeholderWebhookBody}
></textarea>
lang={json()}
theme={$mode == "dark" ? githubDark : githubLight}
styles={{
"&": {
width: "100%",
maxWidth: "100%",
height: "320px",
border: "1px solid hsl(var(--border) / var(--tw-border-opacity))",
borderRadius: "0.375rem"
}
}}
/>
</div>
{/if}
</div>
</div>
{:else if newTrigger.trigger_type == "slack"}
<div class="mt-4 flex w-full flex-col gap-y-2">
<div>
<label class="text-sm font-medium">
<input
on:change={(e) => {
newTrigger.trigger_meta.has_slack_body = e.target.checked;
}}
type="checkbox"
checked={newTrigger.trigger_meta.has_slack_body}
/>
Use Custom Slack Payload
</label>
</div>
{#if !!newTrigger.trigger_meta.has_slack_body}
<p class="my-2 text-xs text-muted-foreground">
You can use a custom slack body. You can use mustache variable to generate a body. See the <a
target="_blank"
class="text-blue-500"
href="https://kener.ing/docs/triggers#triggers-trigger-variables">variables</a
>
that you can use for the slack body.
</p>
<div class="overflow-hidden rounded-md">
<CodeMirror
bind:value={newTrigger.trigger_meta.slack_body}
lang={json()}
theme={$mode == "dark" ? githubDark : githubLight}
styles={{
"&": {
width: "100%",
maxWidth: "100%",
height: "320px",
border: "1px solid hsl(var(--border) / var(--tw-border-opacity))",
borderRadius: "0.375rem"
}
}}
/>
</div>
{/if}
</div>
{:else if newTrigger.trigger_type == "discord"}
<div class="mt-4 flex w-full flex-col gap-y-2">
<div>
<label class="text-sm font-medium">
<input
on:change={(e) => {
newTrigger.trigger_meta.has_discord_body = e.target.checked;
}}
type="checkbox"
checked={newTrigger.trigger_meta.has_discord_body}
/>
Use Custom Discord Payload
</label>
</div>
{#if !!newTrigger.trigger_meta.has_discord_body}
<p class="my-2 text-xs text-muted-foreground">
You can use a custom discord body. You can use mustache variable to generate a body. See the <a
target="_blank"
class="text-blue-500"
href="https://kener.ing/docs/triggers#triggers-trigger-variables">variables</a
>
that you can use for the discord body.
</p>
<div class="overflow-hidden rounded-md">
<CodeMirror
bind:value={newTrigger.trigger_meta.discord_body}
lang={json()}
theme={$mode == "dark" ? githubDark : githubLight}
styles={{
"&": {
width: "100%",
maxWidth: "100%",
height: "320px",
border: "1px solid hsl(var(--border) / var(--tw-border-opacity))",
borderRadius: "0.375rem"
}
}}
/>
</div>
{/if}
</div>
{:else if newTrigger.trigger_type == "email"}
<div class="mt-4 w-full">
<RadioGroup.Root class="my-4 flex" bind:value={newTrigger.trigger_meta.email_type}>
@@ -675,11 +815,11 @@
</div>
<div class="absolute bottom-0 grid h-16 w-full grid-cols-6 justify-end gap-2 border-t p-3 pr-6">
<div class="col-span-5 py-2.5">
<p class="text-right text-xs font-medium text-red-500">{invalidFormMessage}</p>
<p class="text-right text-sm font-medium text-destructive">{invalidFormMessage}</p>
</div>
<div class="col-span-1">
<Button class="col-span-1 w-full" disabled={formState === "loading"} on:click={addNewTrigger}>
Save
Save Trigger
{#if formState === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
+22 -12
View File
@@ -9,7 +9,13 @@
import GMI from "$lib/components/gmi.svelte";
import { page } from "$app/stores";
import { Share2, ArrowRight, Settings, TrendingUp, Loader, ChevronLeft, ChevronRight } from "lucide-svelte";
import Share2 from "lucide-svelte/icons/share-2";
import ArrowRight from "lucide-svelte/icons/arrow-right";
import Settings from "lucide-svelte/icons/settings";
import TrendingUp from "lucide-svelte/icons/trending-up";
import Loader from "lucide-svelte/icons/loader";
import ChevronLeft from "lucide-svelte/icons/chevron-left";
import ChevronRight from "lucide-svelte/icons/chevron-right";
import { buttonVariants } from "$lib/components/ui/button";
import { createEventDispatcher } from "svelte";
import { afterUpdate } from "svelte";
@@ -229,16 +235,6 @@
>
<Share2 class="h-4 w-4 " />
</Button>
{#if monitor.monitor_type === "GROUP"}
<Button
class="bounce-right h-5 p-0 text-muted-foreground hover:text-primary"
variant="link"
href="{base}?group={monitor.tag}"
rel="external"
>
<ArrowRight class="arrow h-4 w-4" />
</Button>
{/if}
</div>
</div>
</div>
@@ -319,7 +315,6 @@
></div>
{#if !!incidents[ts]}
<div
style="transition-delay: {Math.floor(Math.random() * (1500 - 500 + 1)) + 500}ms;"
class="bg-api-{incidents[
ts
].monitor_impact.toLowerCase()} comein absolute -bottom-[3px] left-[1px] h-[4px] w-[4px] rounded-full"
@@ -337,6 +332,21 @@
{/if}
{/each}
</div>
{#if monitor.monitor_type === "GROUP" && !!!embed}
<div class="-mt-4 flex justify-end">
<Button
variant="secondary"
href="{base}?group={monitor.tag}"
rel="external"
class="bounce-right h-8 text-xs"
on:click={scrollToRight}
>
{l(lang, "View in detail")}
<ArrowRight class="arrow ml-1.5 h-4 w-4" />
</Button>
</div>
{/if}
{#if showDailyDataModal}
<div
transition:slide={{ direction: "bottom" }}
+2 -1
View File
@@ -1,7 +1,8 @@
<script>
import { Button } from "$lib/components/ui/button";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { Languages, Menu } from "lucide-svelte";
import Languages from "lucide-svelte/icons/languages";
import Menu from "lucide-svelte/icons/menu";
import { base } from "$app/paths";
import { analyticsEvent } from "$lib/boringOne";
import GMI from "$lib/components/gmi.svelte";
+8 -1
View File
@@ -1,5 +1,12 @@
<script>
import { Share2, Link, CopyCheck, Code, TrendingUp, Percent, Loader, ExternalLink } from "lucide-svelte";
import Share2 from "lucide-svelte/icons/share-2";
import Link from "lucide-svelte/icons/link";
import CopyCheck from "lucide-svelte/icons/copy-check";
import Code from "lucide-svelte/icons/code";
import TrendingUp from "lucide-svelte/icons/trending-up";
import Percent from "lucide-svelte/icons/percent";
import Loader from "lucide-svelte/icons/loader";
import ExternalLink from "lucide-svelte/icons/external-link";
import { Button } from "$lib/components/ui/button";
import { base } from "$app/paths";
import { onMount } from "svelte";
@@ -1,26 +1,26 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import { ChevronDown } from "lucide-svelte";
import { cn } from "$lib/utils";
import { Accordion as AccordionPrimitive } from "bits-ui";
import ChevronDown from "lucide-svelte/icons/chevron-down";
import { cn } from "$lib/utils";
type $$Props = AccordionPrimitive.TriggerProps;
type $$Events = AccordionPrimitive.TriggerEvents;
type $$Props = AccordionPrimitive.TriggerProps;
type $$Events = AccordionPrimitive.TriggerEvents;
let className: $$Props["class"] = undefined;
export let level: AccordionPrimitive.HeaderProps["level"] = 3;
export { className as class };
let className: $$Props["class"] = undefined;
export let level: AccordionPrimitive.HeaderProps["level"] = 3;
export { className as class };
</script>
<AccordionPrimitive.Header {level} class="flex">
<AccordionPrimitive.Trigger
class={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...$$restProps}
on:click
>
<slot />
<ChevronDown class="h-4 w-4 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
<AccordionPrimitive.Trigger
class={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...$$restProps}
on:click
>
<slot />
<ChevronDown class="h-4 w-4 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
@@ -1,35 +1,35 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils";
import { Check } from "lucide-svelte";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils";
import Check from "lucide-svelte/icons/check";
type $$Props = DropdownMenuPrimitive.CheckboxItemProps;
type $$Events = DropdownMenuPrimitive.CheckboxItemEvents;
type $$Props = DropdownMenuPrimitive.CheckboxItemProps;
type $$Events = DropdownMenuPrimitive.CheckboxItemEvents;
let className: $$Props["class"] = undefined;
export let checked: $$Props["checked"] = undefined;
export { className as class };
let className: $$Props["class"] = undefined;
export let checked: $$Props["checked"] = undefined;
export { className as class };
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:checked
class={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50",
className
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerdown
on:pointerleave
on:pointermove
bind:checked
class={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50",
className
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerdown
on:pointerleave
on:pointermove
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.CheckboxIndicator>
<Check class="h-4 w-4" />
</DropdownMenuPrimitive.CheckboxIndicator>
</span>
<slot />
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.CheckboxIndicator>
<Check class="h-4 w-4" />
</DropdownMenuPrimitive.CheckboxIndicator>
</span>
<slot />
</DropdownMenuPrimitive.CheckboxItem>
@@ -1,35 +1,35 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils";
import { Circle } from "lucide-svelte";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils";
import Circle from "lucide-svelte/icons/circle";
type $$Props = DropdownMenuPrimitive.RadioItemProps;
type $$Events = DropdownMenuPrimitive.RadioItemEvents;
type $$Props = DropdownMenuPrimitive.RadioItemProps;
type $$Events = DropdownMenuPrimitive.RadioItemEvents;
let className: $$Props["class"] = undefined;
export let value: $$Props["value"];
export { className as class };
let className: $$Props["class"] = undefined;
export let value: $$Props["value"];
export { className as class };
</script>
<DropdownMenuPrimitive.RadioItem
class={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50",
className
)}
{value}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerdown
on:pointerleave
on:pointermove
class={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50",
className
)}
{value}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerdown
on:pointerleave
on:pointermove
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.RadioIndicator>
<Circle class="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.RadioIndicator>
</span>
<slot />
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.RadioIndicator>
<Circle class="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.RadioIndicator>
</span>
<slot />
</DropdownMenuPrimitive.RadioItem>
@@ -1,32 +1,31 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils";
import { ChevronRight } from "lucide-svelte";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils";
import ChevronRight from "lucide-svelte/icons/chevron-right";
type $$Props = DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
};
type $$Events = DropdownMenuPrimitive.SubTriggerEvents;
type $$Props = DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
};
type $$Events = DropdownMenuPrimitive.SubTriggerEvents;
let className: $$Props["class"] = undefined;
export let inset: $$Props["inset"] = undefined;
export { className as class };
let className: $$Props["class"] = undefined;
export let inset: $$Props["inset"] = undefined;
export { className as class };
</script>
<DropdownMenuPrimitive.SubTrigger
class={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[state=open]:bg-accent data-[highlighted]:text-accent-foreground data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerleave
on:pointermove
class={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[state=open]:bg-accent data-[highlighted]:text-accent-foreground data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerleave
on:pointermove
>
<slot />
<ChevronRight class="ml-auto h-4 w-4" />
<slot />
<ChevronRight class="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
@@ -1,28 +1,28 @@
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
import { Circle } from "lucide-svelte";
import { cn } from "$lib/utils";
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
import Circle from "lucide-svelte/icons/circle";
import { cn } from "$lib/utils";
type $$Props = RadioGroupPrimitive.ItemProps;
type $$Events = RadioGroupPrimitive.ItemEvents;
type $$Props = RadioGroupPrimitive.ItemProps;
type $$Events = RadioGroupPrimitive.ItemEvents;
let className: $$Props["class"] = undefined;
export let value: $$Props["value"];
export { className as class };
let className: $$Props["class"] = undefined;
export let value: $$Props["value"];
export { className as class };
</script>
<RadioGroupPrimitive.Item
{value}
class={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...$$restProps}
on:click
{value}
class={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...$$restProps}
on:click
>
<div class="flex items-center justify-center">
<RadioGroupPrimitive.ItemIndicator>
<Circle class="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.ItemIndicator>
</div>
<div class="flex items-center justify-center">
<RadioGroupPrimitive.ItemIndicator>
<Circle class="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.ItemIndicator>
</div>
</RadioGroupPrimitive.Item>
+30 -30
View File
@@ -1,38 +1,38 @@
<script lang="ts">
import { cn } from "$lib/utils";
import { Select as SelectPrimitive } from "bits-ui";
import { Check } from "lucide-svelte";
import { cn } from "$lib/utils";
import { Select as SelectPrimitive } from "bits-ui";
import Check from "lucide-svelte/icons/check";
type $$Props = SelectPrimitive.ItemProps;
type $$Events = SelectPrimitive.ItemEvents;
type $$Props = SelectPrimitive.ItemProps;
type $$Events = SelectPrimitive.ItemEvents;
let className: $$Props["class"] = undefined;
export let value: $$Props["value"];
export let label: $$Props["label"] = undefined;
export let disabled: $$Props["disabled"] = undefined;
export { className as class };
let className: $$Props["class"] = undefined;
export let value: $$Props["value"];
export let label: $$Props["label"] = undefined;
export let disabled: $$Props["disabled"] = undefined;
export { className as class };
</script>
<SelectPrimitive.Item
{value}
{disabled}
{label}
class={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50",
className
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerleave
on:pointermove
{value}
{disabled}
{label}
class={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50",
className
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerleave
on:pointermove
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check class="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<slot />
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check class="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<slot />
</SelectPrimitive.Item>
@@ -1,27 +1,27 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { ChevronDown } from "lucide-svelte";
import { cn } from "$lib/utils";
import { Select as SelectPrimitive } from "bits-ui";
import ChevronDown from "lucide-svelte/icons/chevron-down";
import { cn } from "$lib/utils";
type $$Props = SelectPrimitive.TriggerProps;
type $$Events = SelectPrimitive.TriggerEvents;
type $$Props = SelectPrimitive.TriggerProps;
type $$Events = SelectPrimitive.TriggerEvents;
let className: $$Props["class"] = undefined;
export { className as class };
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<SelectPrimitive.Trigger
class={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...$$restProps}
let:builder
on:click
on:keydown
class={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...$$restProps}
let:builder
on:click
on:keydown
>
<slot {builder} />
<div>
<ChevronDown class="h-4 w-4 opacity-50" />
</div>
<slot {builder} />
<div>
<ChevronDown class="h-4 w-4 opacity-50" />
</div>
</SelectPrimitive.Trigger>
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "VERFÜGBAR",
"Upcoming Maintenance": "Bevorstehende Wartung",
"Updates": "Updates",
"Uptime": "Verfügbarkeit"
"Uptime": "Verfügbarkeit",
"View in detail": "Details anzeigen"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "OPPE",
"Upcoming Maintenance": "Kommende vedligeholdelse",
"Updates": "Opdateringer",
"Uptime": "Oppetid"
"Uptime": "Oppetid",
"View in detail": "Vis i detaljer"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "UP",
"Upcoming Maintenance": "Upcoming Maintenance",
"Updates": "Updates",
"Uptime": "Uptime"
"Uptime": "Uptime",
"View in detail": "View in detail"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "En ligne",
"Upcoming Maintenance": "Maintenance à venir",
"Updates": "Mises à jour",
"Uptime": "Temps de fonctionnement"
"Uptime": "Temps de fonctionnement",
"View in detail": "Voir en détail"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "अप",
"Upcoming Maintenance": "आगामी मेंटेनेंस",
"Updates": "अपडेट्स",
"Uptime": "अपटाइम"
"Uptime": "अपटाइम",
"View in detail": "विस्तार में देखें"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "稼働中",
"Upcoming Maintenance": "今後のメンテナンス",
"Updates": "更新",
"Uptime": "稼働時間"
"Uptime": "稼働時間",
"View in detail": "詳細を表示"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "작동 중",
"Upcoming Maintenance": "예정된 유지보수",
"Updates": "업데이트",
"Uptime": "업타임"
"Uptime": "업타임",
"View in detail": "자세히 보기"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "OPPE",
"Upcoming Maintenance": "Kommende vedlikehold",
"Updates": "Oppdateringer",
"Uptime": "Oppetid"
"Uptime": "Oppetid",
"View in detail": "Vis detaljer"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "BEREIKBAAR",
"Upcoming Maintenance": "Aankomend onderhoud",
"Updates": "Updates",
"Uptime": "Uptime"
"Uptime": "Uptime",
"View in detail": "Bekijk in detail"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "OPERACIONAL",
"Upcoming Maintenance": "Manutenção Agendada",
"Updates": "Atualizações",
"Uptime": "Tempo de Atividade"
"Uptime": "Tempo de Atividade",
"View in detail": "Ver em detalhes"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "РАБОТАЕТ",
"Upcoming Maintenance": "Предстоящее техническое обслуживание",
"Updates": "Обновления",
"Uptime": "Время работы"
"Uptime": "Время работы",
"View in detail": "Подробный просмотр"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "ÇALIŞIYOR",
"Upcoming Maintenance": "Yaklaşan Bakım",
"Updates": "Güncellemeler",
"Uptime": "Çalışma Süresi"
"Uptime": "Çalışma Süresi",
"View in detail": "Detaylı Görüntüle"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "Hoạt động",
"Upcoming Maintenance": "Bảo trì sắp tới",
"Updates": "Cập nhật",
"Uptime": "Thời gian hoạt động"
"Uptime": "Thời gian hoạt động",
"View in detail": "Xem chi tiết"
}
+2 -1
View File
@@ -60,5 +60,6 @@
"UP": "可用",
"Upcoming Maintenance": "即将进行的维护",
"Updates": "更新",
"Uptime": "正常运行时间"
"Uptime": "正常运行时间",
"View in detail": "详细查看"
}
+2
View File
@@ -104,6 +104,7 @@ const MANUAL = "manual";
const WEBHOOK = "webhook";
const DEFAULT_STATUS = "default_status";
const SIGNAL = "signal";
const INVITE_VERIFY_EMAIL = "invite_verify_email";
export {
MONITOR,
@@ -123,4 +124,5 @@ export {
WEBHOOK,
DEFAULT_STATUS,
SIGNAL,
INVITE_VERIFY_EMAIL,
};
+284 -7
View File
@@ -15,10 +15,13 @@ import {
import db from "../db/db.js";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { Resend } from "resend";
import crypto from "crypto";
import { format, subMonths, addMonths, startOfMonth } from "date-fns";
import { UP, DOWN, DEGRADED, NO_DATA, REALTIME, SIGNAL } from "../constants.js";
import { GetMinuteStartNowTimestampUTC, GetNowTimestampUTC } from "../tool.js";
import { GetMinuteStartNowTimestampUTC, GetNowTimestampUTC, ReplaceAllOccurrences } from "../tool.js";
import getSMTPTransport from "../notification/smtps.js";
const saltRounds = 10;
const DUMMY_SECRET = "DUMMY_SECRET";
@@ -302,6 +305,23 @@ export const VerifyPassword = async (plainTextPassword, hashedPassword) => {
export const GetLatestMonitoringData = async (monitor_tag) => {
return await db.getLatestMonitoringData(monitor_tag);
};
export const GetLatestStatusActiveAll = async (monitor_tags) => {
let latestData = await db.getLatestMonitoringDataAllActive(monitor_tags);
let status = "NO_DATA";
for (let i = 0; i < latestData.length; i++) {
//if any status is down then status = down, if any is degraded then status = degraded, down > degraded > up
if (latestData[i].status === "DOWN") {
status = "DOWN";
} else if (latestData[i].status === "DEGRADED" && status !== "DOWN") {
status = "DEGRADED";
} else if (latestData[i].status === "UP" && status !== "DOWN" && status !== "DEGRADED") {
status = "UP";
}
}
return {
status: status,
};
};
export const GetLastHeartbeat = async (monitor_tag) => {
return await db.getLastHeartbeat(monitor_tag);
};
@@ -397,6 +417,12 @@ export const CreateNewAPIKey = async (data) => {
const apiKey = generateApiKey();
const hashed_key = await createHash(apiKey);
//insert into db
//data.name cant be empty
if (!data.name) {
throw new Error("Name is required");
}
await db.createNewApiKey({
name: data.name,
hashed_key: hashed_key,
@@ -438,12 +464,6 @@ export const IsSetupComplete = async () => {
return data.length > 0;
};
export const HashString = (str) => {
const hash = crypto.createHash("sha256");
hash.update(str);
return hash.digest("hex");
};
export const InterpolateData = (rawData, startTimestamp, initialStatus, overrideEndTimestamp) => {
const interpolatedData = [];
let currentStatus = initialStatus || "UP";
@@ -479,6 +499,19 @@ export const GetLastStatusBefore = async (monitor_tag, timestamp) => {
}
return NO_DATA;
};
export const GetMonitoringData = async (tag, since, now) => {
return await db.getMonitoringData(tag, since, now);
};
export const GetMonitoringDataAll = async (tags, since, now) => {
return await db.getMonitoringDataAll(tags, since, now);
};
export const GetLastStatusBeforeAll = async (monitor_tags, timestamp) => {
let data = await db.getLastStatusBeforeAll(monitor_tags, timestamp);
if (data) {
return data.status;
}
return NO_DATA;
};
export const AggregateData = (rawData) => {
//data like [{ timestamp: 1732435920, status: 'NO_DATA' }]
@@ -899,6 +932,250 @@ export const GetSMTPFromENV = () => {
};
};
export const IsResendSetup = () => {
return !!process.env.RESEND_API_KEY && !!process.env.RESEND_SENDER_EMAIL;
};
export const IsEmailSetup = () => {
return !!GetSMTPFromENV || IsResendSetup();
};
export const UpdateUserData = async (data) => {
let userID = data.userID;
let updateKey = data.updateKey;
let updateValue = data.updateValue;
//if updateKey is password, throw error
if (updateKey === "password") {
throw new Error("Password cannot be updated using this method");
}
//if updateValue is empty, throw error
if (!!!updateValue) {
throw new Error("Update value cannot be empty");
}
switch (updateKey) {
case "name":
return await db.updateUserName(userID, updateValue);
case "role":
return await db.updateUserRole(userID, updateValue);
case "is_verified":
return await db.updateIsVerified(userID, updateValue);
default:
throw new Error("Invalid update key");
}
};
export const CreateNewInvitation = async (data) => {
let invite = {};
//create a token
let token = crypto.randomBytes(32).toString("hex");
let hashedToken = createHash(token);
let invitation_token = data.invitation_type.toLowerCase() + "_" + hashedToken;
invite.invitation_token = invitation_token;
invite.invitation_type = data.invitation_type;
invite.invited_user_id = data.invited_user_id;
invite.invited_by_user_id = data.invited_by_user_id;
invite.invitation_meta = data.invitation_meta;
invite.invitation_expiry = data.invitation_expiry;
invite.invitation_status = "PENDING";
//update old invitations to VOID
await db.updateInvitationStatusToVoid(invite.invited_user_id, invite.invitation_type);
let res = await db.insertInvitation(invite);
return {
invitation_token,
};
};
//check if there is a row for given invited_user_id,invitation_type and invitation_status = PENDING
export const CheckInvitationExists = async (invited_user_id, invitation_type) => {
let invitation = await db.invitationExists(invited_user_id, invitation_type);
return !!invitation;
};
//getInvitationByToken
export const GetActiveInvitationByToken = async (invitation_token) => {
let invitation = await db.getActiveInvitationByToken(invitation_token);
return invitation;
};
//updateInvitationStatusToAccepted
export const UpdateInvitationStatusToAccepted = async (invitation_token) => {
return await db.updateInvitationStatusToAccepted(invitation_token);
};
//getUserById
export const GetUserByID = async (userID) => {
return await db.getUserById(userID);
};
//getUserByEmail
export const GetUserByEmail = async (email) => {
return await db.getUserByEmail(email);
};
export const SendEmailWithTemplate = async (template, data, email, subject, emailText) => {
//for each key in data, replace the key in template with value
for (const key in data) {
if (Object.hasOwnProperty.call(data, key)) {
const value = data[key];
template = ReplaceAllOccurrences(template, `{{${key}}}`, value);
}
}
const senderEmail = process.env.RESEND_SENDER_EMAIL;
const resendKey = process.env.RESEND_API_KEY;
let mail = {
from: senderEmail,
to: [email],
subject: subject,
text: emailText,
html: template,
};
let smtpData = GetSMTPFromENV();
try {
if (!!smtpData) {
const transporter = getSMTPTransport(smtpData);
const mailOptions = {
from: smtpData.smtp_from_email,
to: email,
subject: mail.subject,
html: mail.html,
text: mail.text,
};
return await transporter.sendMail(mailOptions);
} else {
const resend = new Resend(resendKey);
return await resend.emails.send(mail);
}
} catch (error) {
console.error("Error sending email via SMTP", error);
throw new Error("Error sending email v");
}
};
export const GetSiteLogoURL = async (siteURL, logo, base) => {
if (logo.startsWith("http")) {
return logo;
}
return siteURL + base + logo;
};
export const GetAllUsers = async () => {
return await db.getAllUsers();
};
//get all users paginated
export const GetAllUsersPaginated = async (data) => {
return await db.getUsersPaginated(data.page, data.limit);
};
//given a limit return total pages
export const GetTotalUserPages = async (limit) => {
let totalUsers = await db.getTotalUsers();
let totalPages = Math.ceil(totalUsers.count / limit);
return totalPages;
};
export const ValidatePassword = (password) => {
return /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).{8,}$/.test(password);
};
export const UpdatePassword = async (data) => {
let { userID, newPassword, newPlainPassword } = data;
if (!ValidatePassword(newPassword)) {
throw new Error(
"Password must contain at least 8 characters, one uppercase letter, one lowercase letter and one number",
);
}
// newPassword should match newPlainPassword
if (newPassword !== newPlainPassword) {
throw new Error("Passwords do not match");
}
//hash the password
let hashedPassword = await HashPassword(newPassword);
return await db.updateUserPassword({
id: userID,
password_hash: hashedPassword,
});
};
export const ManualUpdateUserData = async (byUser, forUserId, data) => {
let forUser = await db.getUserById(forUserId);
//only admins can update
if (byUser.role !== "admin") {
throw new Error("You do not have permission to update user");
}
if (data.updateType == "role") {
return await db.updateUserRole(forUser.id, data.role);
} else if (data.updateType == "is_active") {
return await db.updateUserIsActive(forUser.id, data.is_active);
} else if (data.updateType == "password") {
return await UpdatePassword({
userID: forUser.id,
newPassword: data.password,
newPlainPassword: data.passwordPlain,
});
}
};
export const CreateNewUser = async (currentUser, data) => {
let acceptedRoles = ["member", "editor"];
if (!acceptedRoles.includes(data.role)) {
throw new Error("Invalid role");
}
if (currentUser.role === "member") {
throw new Error("Only admins and editors can create new users");
}
//if data.email empty, throw error
if (!!!data.email) {
throw new Error("Email cannot be empty");
}
//if data.name empty, throw error
if (!!!data.name) {
throw new Error("Name cannot be empty");
}
//if data.password empty, throw error
if (!!!data.password) {
throw new Error("Password cannot be empty");
}
//if data.role empty, throw error
if (!!!data.role) {
throw new Error("Role cannot be empty");
}
//if data.password not equal to data.plainPassword, throw error
if (data.password !== data.plainPassword) {
throw new Error("Passwords do not match");
}
if (!ValidatePassword(data.password)) {
throw new Error(
"Password must contain at least 8 characters, one uppercase letter, one lowercase letter and one number",
);
}
let user = {
email: data.email,
password_hash: await HashPassword(data.password),
name: data.name,
role: data.role,
};
return await db.insertUser(user);
};
export const GetSiteMap = async (cookies) => {
let siteMapData = [];
let siteURLData = await GetSiteDataByKey("siteURL");
+83 -73
View File
@@ -66,90 +66,100 @@ async function manualIncident(monitor) {
}
const Minuter = async (monitor) => {
let realTimeData = {};
let manualData = {};
try {
let realTimeData = {};
let manualData = {};
const startOfMinute = GetMinuteStartNowTimestampUTC();
const serviceClient = new Service(monitor);
if (monitor.monitor_type === "API") {
let apiResponse = await serviceClient.execute();
const startOfMinute = GetMinuteStartNowTimestampUTC();
const serviceClient = new Service(monitor);
if (monitor.monitor_type === "API") {
let apiResponse = await serviceClient.execute();
realTimeData[startOfMinute] = apiResponse;
realTimeData[startOfMinute] = apiResponse;
//if timeout, retry after 500ms
if (apiResponse.type === TIMEOUT) {
apiQueue.push(async (cb) => {
await Wait(500); //wait for 500ms
console.log("Retrying api call for " + monitor.name + " at " + startOfMinute + " due to timeout");
serviceClient.execute().then(async (data) => {
await db.insertMonitoringData({
monitor_tag: monitor.tag,
timestamp: startOfMinute,
status: data.status,
latency: data.latency,
type: data.type,
//if timeout, retry after 500ms
if (apiResponse.type === TIMEOUT) {
apiQueue.push(async (cb) => {
await Wait(500); //wait for 500ms
console.log("Retrying api call for " + monitor.name + " at " + startOfMinute + " due to timeout");
serviceClient.execute().then(async (data) => {
await db.insertMonitoringData({
monitor_tag: monitor.tag,
timestamp: startOfMinute,
status: data.status,
latency: data.latency,
type: data.type,
});
cb();
});
cb();
});
}
} else if (monitor.monitor_type === "PING") {
realTimeData[startOfMinute] = await serviceClient.execute();
} else if (monitor.monitor_type === "TCP") {
realTimeData[startOfMinute] = await serviceClient.execute();
} else if (monitor.monitor_type === "DNS") {
realTimeData[startOfMinute] = await serviceClient.execute();
} else if (monitor.monitor_type === "GROUP") {
realTimeData[startOfMinute] = await serviceClient.execute(startOfMinute);
} else if (monitor.monitor_type === "SSL") {
realTimeData[startOfMinute] = await serviceClient.execute();
} else if (monitor.monitor_type === "SQL") {
realTimeData[startOfMinute] = await serviceClient.execute();
} else if (monitor.monitor_type === "HEARTBEAT") {
realTimeData[startOfMinute] = await serviceClient.execute();
}
manualData = await manualIncident(monitor);
//merge noData, apiData, webhookData, dayData
let mergedData = {};
if (monitor.default_status !== undefined && monitor.default_status !== null) {
if ([UP, DOWN, DEGRADED].indexOf(monitor.default_status) !== -1) {
mergedData[startOfMinute] = {
status: monitor.default_status,
latency: 0,
type: DEFAULT_STATUS,
};
}
}
for (const timestamp in realTimeData) {
if (!!realTimeData[timestamp] && !!realTimeData[timestamp].status) {
mergedData[timestamp] = realTimeData[timestamp];
}
}
for (const timestamp in manualData) {
if (!!manualData[timestamp] && !!manualData[timestamp].status) {
mergedData[timestamp] = manualData[timestamp];
}
}
for (const timestamp in mergedData) {
const element = mergedData[timestamp];
db.insertMonitoringData({
monitor_tag: monitor.tag,
timestamp: parseInt(timestamp),
status: element.status,
latency: element.latency,
type: element.type,
});
}
} else if (monitor.monitor_type === "PING") {
realTimeData[startOfMinute] = await serviceClient.execute();
} else if (monitor.monitor_type === "TCP") {
realTimeData[startOfMinute] = await serviceClient.execute();
} else if (monitor.monitor_type === "DNS") {
realTimeData[startOfMinute] = await serviceClient.execute();
} else if (monitor.monitor_type === "GROUP") {
realTimeData[startOfMinute] = await serviceClient.execute(startOfMinute);
} else if (monitor.monitor_type === "SSL") {
realTimeData[startOfMinute] = await serviceClient.execute();
} else if (monitor.monitor_type === "SQL") {
realTimeData[startOfMinute] = await serviceClient.execute();
} else if (monitor.monitor_type === "HEARTBEAT") {
realTimeData[startOfMinute] = await serviceClient.execute();
} catch (e) {
console.log(`[Error] in Running Monitor name: ${monitor.name}, tag: ${monitor.tag} error:`, e);
}
manualData = await manualIncident(monitor);
//merge noData, apiData, webhookData, dayData
let mergedData = {};
if (monitor.default_status !== undefined && monitor.default_status !== null) {
if ([UP, DOWN, DEGRADED].indexOf(monitor.default_status) !== -1) {
mergedData[startOfMinute] = {
status: monitor.default_status,
latency: 0,
type: DEFAULT_STATUS,
};
}
}
for (const timestamp in realTimeData) {
if (!!realTimeData[timestamp] && !!realTimeData[timestamp].status) {
mergedData[timestamp] = realTimeData[timestamp];
}
}
for (const timestamp in manualData) {
if (!!manualData[timestamp] && !!manualData[timestamp].status) {
mergedData[timestamp] = manualData[timestamp];
}
}
for (const timestamp in mergedData) {
const element = mergedData[timestamp];
db.insertMonitoringData({
monitor_tag: monitor.tag,
timestamp: parseInt(timestamp),
status: element.status,
latency: element.latency,
type: element.type,
});
}
alertingQueue.push(async (cb) => {
setTimeout(async () => {
await alerting(monitor);
cb();
try {
await alerting(monitor);
cb();
} catch (e) {
console.log(`[Error] in Running Alerting name: ${monitor.name}, tag: ${monitor.tag} error:`, e);
cb();
}
}, 1042);
});
};
+169
View File
@@ -30,6 +30,14 @@ class DbImpl {
.where("timestamp", "<=", end)
.orderBy("timestamp", "asc");
}
//given monitor_tags array of string, start and end timestamp in utc seconds return data
async getMonitoringDataAll(monitor_tags, start, end) {
return await this.knex("monitoring_data")
.whereIn("monitor_tag", monitor_tags)
.where("timestamp", ">=", start)
.where("timestamp", "<=", end)
.orderBy("timestamp", "asc");
}
//get latest data for a monitor_tag
async getLatestMonitoringData(monitor_tag) {
@@ -40,6 +48,38 @@ class DbImpl {
.first();
}
//get latest data for all active monitors
async getLatestMonitoringDataAllActive(monitor_tags) {
// Find the latest timestamp for each provided monitor tag
const latestTimestamps = await this.knex("monitoring_data")
.select("monitor_tag")
.select(this.knex.raw("MAX(timestamp) as max_timestamp"))
.whereIn("monitor_tag", monitor_tags)
.groupBy("monitor_tag");
// Early exit if no results
if (!latestTimestamps || latestTimestamps.length === 0) {
return [];
}
// Then fetch the complete records using the timestamp pairs
const conditions = latestTimestamps.map((item) => {
return function () {
this.where(function () {
this.where("monitor_tag", item.monitor_tag).andWhere("timestamp", item.max_timestamp);
});
};
});
// Build query with OR conditions
let query = this.knex("monitoring_data").where(conditions[0]);
for (let i = 1; i < conditions.length; i++) {
query = query.orWhere(conditions[i]);
}
return await query;
}
async getLastHeartbeat(monitor_tag) {
return await this.knex("monitoring_data")
.where("monitor_tag", monitor_tag)
@@ -75,6 +115,15 @@ class DbImpl {
.limit(1)
.first();
}
//get the last status before the timestamp given monitor_tag and start timestamp
async getLastStatusBeforeAll(monitor_tags, timestamp) {
return await this.knex("monitoring_data")
.whereIn("monitor_tag", monitor_tags)
.where("timestamp", "<", timestamp)
.orderBy("timestamp", "desc")
.limit(1)
.first();
}
async getDataGroupByDayAlternative(monitor_tag, start, end) {
// Fetch all raw data
@@ -403,6 +452,14 @@ class DbImpl {
return await this.knex("users").select("password_hash").where("id", id).first();
}
//get user name, email from users given id
async getUserById(id) {
return await this.knex("users")
.select("id", "email", "name", "is_active", "is_verified", "role", "created_at", "updated_at")
.where("id", id)
.first();
}
//insert user
async insertUser(data) {
return await this.knex("users").insert({
@@ -423,6 +480,27 @@ class DbImpl {
});
}
//get all columns of users except password_hash order by create at
async getAllUsers() {
return await this.knex("users")
.select("id", "email", "name", "role", "is_active", "is_verified", "created_at", "updated_at")
.orderBy("created_at", "desc");
}
//get all users paginated
async getUsersPaginated(page, limit) {
return await this.knex("users")
.select("id", "email", "name", "role", "is_active", "is_verified", "created_at", "updated_at")
.orderBy("created_at", "desc")
.limit(limit)
.offset((page - 1) * limit);
}
//getTotalUsers
async getTotalUsers() {
return await this.knex("users").count("* as count").first();
}
//new api key
async createNewApiKey(data) {
return await this.knex("api_keys").insert({
@@ -742,6 +820,97 @@ class DbImpl {
async getIncidentCommentByID(id) {
return await this.knex("incident_comments").where({ id }).first();
}
//update users.name by id
async updateUserName(id, name) {
return await this.knex("users").where({ id }).update({
name,
updated_at: this.knex.fn.now(),
});
}
//updateUserRole
async updateUserRole(id, role) {
return await this.knex("users").where({ id }).update({
role,
updated_at: this.knex.fn.now(),
});
}
//update is_active
async updateUserIsActive(id, is_active) {
return await this.knex("users").where({ id }).update({
is_active,
updated_at: this.knex.fn.now(),
});
}
//update password
async updateUserPassword(data) {
return await this.knex("users").where({ id: data.id }).update({
password_hash: data.password_hash,
updated_at: this.knex.fn.now(),
});
}
async updateIsVerified(id, is_verified) {
return await this.knex("users").where({ id }).update({
is_verified: is_verified,
updated_at: this.knex.fn.now(),
});
}
//insert into invitations
async insertInvitation(data) {
return await this.knex("invitations").insert({
invitation_token: data.invitation_token,
invitation_type: data.invitation_type,
invited_user_id: data.invited_user_id,
invited_by_user_id: data.invited_by_user_id,
invitation_meta: data.invitation_meta,
invitation_expiry: data.invitation_expiry,
invitation_status: data.invitation_status,
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
});
}
//update invitation_status of all invitations for invited_user_id, given invitation_type to VOID
async updateInvitationStatusToVoid(invited_user_id, invitation_type) {
return await this.knex("invitations").where({ invited_user_id, invitation_type }).update({
invitation_status: "VOID",
updated_at: this.knex.fn.now(),
});
}
//check if there is a row for given invited_user_id,invitation_type and invitation_status = PENDING
async invitationExists(invited_user_id, invitation_type) {
const result = await this.knex("invitations")
.count("* as count")
.where({
invited_user_id,
invitation_type,
invitation_status: "PENDING",
})
.first();
return result.count > 0;
}
//update invitation_status of invitation given invitation_token to ACCEPTED
async updateInvitationStatusToAccepted(invitation_token) {
return await this.knex("invitations").where({ invitation_token }).update({
invitation_status: "ACCEPTED",
updated_at: this.knex.fn.now(),
});
}
//get invitations by token, the invitation should be PENDING, and it should not be expired
async getActiveInvitationByToken(invitation_token) {
return await this.knex("invitations")
.where("invitation_token", invitation_token)
.andWhere("invitation_status", "PENDING")
.andWhere("invitation_expiry", ">", this.knex.fn.now())
.first();
}
}
export default DbImpl;
+33 -60
View File
@@ -1,15 +1,22 @@
// @ts-nocheck
import Mustache from "mustache";
import { DiscordJSONTemplate } from "../../anywhere.js";
import variables from "./variables.js";
import { GetRequiredSecrets, ReplaceAllOccurrences } from "../tool.js";
import version from "../../version.js";
class Discord {
url;
headers;
method;
siteData;
monitorData;
trigger_meta;
constructor(url, siteData, monitorData) {
constructor(url, siteData, monitorData, trigger_meta) {
const kenerHeader = {
"Content-Type": "application/json",
"User-Agent": "Kener",
"User-Agent": `Kener/${version()}`,
};
this.url = url;
@@ -17,77 +24,43 @@ class Discord {
this.method = "POST";
this.siteData = siteData;
this.monitorData = monitorData;
this.trigger_meta = trigger_meta;
if (!!this.trigger_meta.has_discord_body && !!this.trigger_meta.discord_body) {
let envSecrets = GetRequiredSecrets(this.trigger_meta.discord_body);
for (let i = 0; i < envSecrets.length; i++) {
const secret = envSecrets[i];
this.trigger_meta.discord_body = ReplaceAllOccurrences(
this.trigger_meta.discord_body,
secret.find,
secret.replace,
);
}
}
}
transformData(data) {
let siteURL = this.siteData.siteURL;
let view = variables(this.siteData, data);
//remove / if there at the end of the url
if (siteURL.endsWith("/")) {
siteURL = siteURL.slice(0, -1);
let discordTemplate = DiscordJSONTemplate;
if (!!this.trigger_meta.has_discord_body && !!this.trigger_meta.discord_body) {
discordTemplate = this.trigger_meta.discord_body;
}
if (!this.siteData.logo.startsWith("http"))
this.siteData.logo = `${siteURL}${!!process.env.KENER_BASE_PATH ? process.env.KENER_BASE_PATH : ""}${this.siteData.logo}`;
let logo = this.siteData.logo;
let color = 13250616; //down;
if (data.severity === "warning") {
color = 15125089;
}
if (data.status === "RESOLVED") {
color = 5156244;
}
return {
username: this.siteData.siteName,
avatar_url: logo,
content: `## ${data.alert_name}\n${data.status === "TRIGGERED" ? "🔴 Triggered" : "🟢 Resolved"}\n${data.description}\nClick [here](${data.actions[0].url}) for more.`,
embeds: [
{
title: data.alert_name,
description: data.description,
url: data.actions[0].url,
color: color,
fields: [
{
name: "Monitor",
value: data.details.metric,
inline: false,
},
{
name: "Alert ID",
value: data.id,
inline: false,
},
{
name: "Current Value",
value: data.details.current_value,
inline: true,
},
{
name: "Threshold",
value: data.details.threshold,
inline: true,
},
],
footer: {
text: "Kener",
icon_url: logo,
},
timestamp: data.timestamp,
},
],
};
return Mustache.render(discordTemplate, view);
}
async send(data) {
try {
const toSend = this.transformData(data);
const response = await fetch(this.url, {
method: this.method,
headers: this.headers,
body: JSON.stringify(this.transformData(data)),
body: toSend,
});
if (!response.ok) {
throw new Error(`Error from discord`);
}
return response;
} catch (error) {
console.error("Error sending webhook", error);
+76 -75
View File
@@ -3,56 +3,57 @@ import { Resend } from "resend";
import nodemailer from "nodemailer";
import getSMTPTransport from "./smtps.js";
import { GetRequiredSecrets, ReplaceAllOccurrences } from "../tool.js";
import version from "../../version.js";
class Email {
to;
from;
method;
siteData;
monitorData;
meta;
to;
from;
method;
siteData;
monitorData;
meta;
constructor(meta, siteData, monitorData) {
this.to = meta.to;
this.from = meta.from;
this.siteData = siteData;
this.monitorData = monitorData;
constructor(meta, siteData, monitorData) {
this.to = meta.to;
this.from = meta.from;
this.siteData = siteData;
this.monitorData = monitorData;
let metaString = JSON.stringify(meta);
let metaString = JSON.stringify(meta);
let envSecrets = GetRequiredSecrets(`${JSON.stringify(meta)}`);
let envSecrets = GetRequiredSecrets(`${JSON.stringify(meta)}`);
for (let i = 0; i < envSecrets.length; i++) {
const secret = envSecrets[i];
metaString = ReplaceAllOccurrences(metaString, secret.find, secret.replace);
}
for (let i = 0; i < envSecrets.length; i++) {
const secret = envSecrets[i];
metaString = ReplaceAllOccurrences(metaString, secret.find, secret.replace);
}
this.meta = JSON.parse(metaString);
}
this.meta = JSON.parse(metaString);
}
transformData(data) {
let ctaLink = data.actions[0].url;
let ctaText = data.actions[0].text;
let emailApiRequest = {
from: this.from,
to: this.to.split(",").map((email) => email.trim()),
subject: `[${data.status}] ${data.alert_name} at ${data.timestamp}`,
text: `Alert ${data.alert_name} has been ${data.status} at ${data.timestamp}. Click here to view the alert: ${ctaLink}`,
html: ""
};
transformData(data) {
let ctaLink = data.actions[0].url;
let ctaText = data.actions[0].text;
let emailApiRequest = {
from: this.from,
to: this.to.split(",").map((email) => email.trim()),
subject: `[${data.status}] ${data.alert_name} at ${data.timestamp}`,
text: `Alert ${data.alert_name} has been ${data.status} at ${data.timestamp}. Click here to view the alert: ${ctaLink}`,
html: "",
};
let bgColor = "#f4f4f4";
if (data.severity == "critical") {
bgColor = this.siteData.colors.DOWN;
} else if (data.severity == "warning") {
bgColor = this.siteData.colors.DEGRADED;
}
let bgColor = "#f4f4f4";
if (data.severity == "critical") {
bgColor = this.siteData.colors.DOWN;
} else if (data.severity == "warning") {
bgColor = this.siteData.colors.DEGRADED;
}
if (data.status === "RESOLVED") {
bgColor = this.siteData.colors.UP;
}
if (data.status === "RESOLVED") {
bgColor = this.siteData.colors.UP;
}
let html = `
let html = `
<!DOCTYPE html>
<html>
<head>
@@ -177,53 +178,53 @@ class Email {
</table>
<div class="footer">
This is an automated alert notification from ${this.siteData.siteName} monitoring system.
This is an automated alert notification from ${this.siteData.siteName} monitoring system. It is being powered by <a href='https://kener.ing'>Kener</a> v${version()}.
</div>
</div>
</body>
</html>
`;
emailApiRequest.html = html;
emailApiRequest.html = html;
return emailApiRequest;
}
return emailApiRequest;
}
type() {
return "email";
}
type() {
return "email";
}
async send(data) {
let emailBody = this.transformData(data); // object containing email data (to, subject, text, html, etc)
if (!this.meta.email_type || this.meta.email_type === "resend") {
const resend = new Resend(process.env.RESEND_API_KEY);
try {
return await resend.emails.send(emailBody);
} catch (error) {
console.error("Error sending webhook", error);
return error;
}
}
if (this.meta.email_type === "smtp") {
// Configure the SMTP transporter using environment variables
const transporter = getSMTPTransport(this.meta);
async send(data) {
let emailBody = this.transformData(data); // object containing email data (to, subject, text, html, etc)
if (!this.meta.email_type || this.meta.email_type === "resend") {
const resend = new Resend(process.env.RESEND_API_KEY);
try {
return await resend.emails.send(emailBody);
} catch (error) {
console.error("Error sending webhook", error);
return error;
}
}
if (this.meta.email_type === "smtp") {
// Configure the SMTP transporter using environment variables
const transporter = getSMTPTransport(this.meta);
const mailOptions = {
from: emailBody.from, // sender address
to: Array.isArray(emailBody.to) ? emailBody.to.join(",") : emailBody.to, // recipient address(es)
subject: emailBody.subject, // email subject
text: emailBody.text, // plain text body
html: emailBody.html // HTML body (if any)
};
const mailOptions = {
from: emailBody.from, // sender address
to: Array.isArray(emailBody.to) ? emailBody.to.join(",") : emailBody.to, // recipient address(es)
subject: emailBody.subject, // email subject
text: emailBody.text, // plain text body
html: emailBody.html, // HTML body (if any)
};
try {
return await transporter.sendMail(mailOptions);
} catch (error) {
console.error("Error sending email via SMTP", error);
return error;
}
}
}
try {
return await transporter.sendMail(mailOptions);
} catch (error) {
console.error("Error sending email via SMTP", error);
return error;
}
}
}
}
export default Email;
+19 -19
View File
@@ -5,26 +5,26 @@ import Slack from "./slack.js";
import Email from "./email.js";
class Notification {
client;
client;
constructor(trigger, siteData, monitorData) {
let trigger_meta = JSON.parse(trigger.trigger_meta);
if (trigger.trigger_type === "webhook") {
this.client = new Webhook(trigger_meta, "POST", siteData, monitorData);
} else if (trigger.trigger_type === "discord") {
this.client = new Discord(trigger_meta.url, siteData, monitorData);
} else if (trigger.trigger_type === "slack") {
this.client = new Slack(trigger_meta.url, siteData, monitorData);
} else if (trigger.trigger_type === "email") {
this.client = new Email(trigger_meta, siteData, monitorData);
} else {
console.log("Invalid Notification");
process.exit(1);
}
}
constructor(trigger, siteData, monitorData) {
let trigger_meta = JSON.parse(trigger.trigger_meta);
if (trigger.trigger_type === "webhook") {
this.client = new Webhook(trigger_meta, "POST", siteData, monitorData);
} else if (trigger.trigger_type === "discord") {
this.client = new Discord(trigger_meta.url, siteData, monitorData, trigger_meta);
} else if (trigger.trigger_type === "slack") {
this.client = new Slack(trigger_meta.url, siteData, monitorData, trigger_meta);
} else if (trigger.trigger_type === "email") {
this.client = new Email(trigger_meta, siteData, monitorData);
} else {
console.log("Invalid Notification");
process.exit(1);
}
}
async send(data) {
return await this.client.send(data);
}
async send(data) {
return await this.client.send(data);
}
}
export default Notification;
+57 -91
View File
@@ -1,100 +1,66 @@
// @ts-nocheck
import variables from "./variables.js";
import { SlackJSONTemplate } from "../../anywhere.js";
import Mustache from "mustache";
import { GetRequiredSecrets, ReplaceAllOccurrences } from "../tool.js";
import version from "../../version.js";
class Slack {
url;
headers;
method;
siteData;
monitorData;
url;
headers;
method;
siteData;
monitorData;
trigger_meta;
constructor(url, siteData, monitorData) {
const kenerHeader = {
"Content-Type": "application/json",
"User-Agent": "Kener"
};
constructor(url, siteData, monitorData, trigger_meta) {
const kenerHeader = {
"Content-Type": "application/json",
"User-Agent": `Kener/${version()}`,
};
this.url = url;
this.headers = Object.assign(kenerHeader, {});
this.method = "POST";
this.siteData = siteData;
this.monitorData = monitorData;
}
this.url = url;
this.headers = Object.assign(kenerHeader, {});
this.method = "POST";
this.siteData = siteData;
this.monitorData = monitorData;
this.trigger_meta = trigger_meta;
transformData(alert) {
return {
blocks: [
{
type: "header",
text: {
type: "plain_text",
text: alert.alert_name,
emoji: true
}
},
{
type: "header",
text: {
type: "plain_text",
text: alert.status === "TRIGGERED" ? "🔴 Triggered" : "🟢 Resolved",
emoji: true
}
},
{
type: "section",
text: {
type: "mrkdwn",
text: `${alert.description}\n*Source:* ${alert.source}\n*Severity:* ${alert.severity}\n*Status:* ${alert.status}`
}
},
{
type: "section",
fields: [
{
type: "mrkdwn",
text: `*Metric:*\n${alert.details.metric}`
},
{
type: "mrkdwn",
text: `*Current Value:*\n${alert.details.current_value}`
},
{
type: "mrkdwn",
text: `*Threshold:*\n${alert.details.threshold}`
},
{
type: "mrkdwn",
text: `*Timestamp:*\n<!date^${Math.floor(new Date(alert.timestamp).getTime() / 1000)}^{date} at {time}|${alert.timestamp}>`
}
]
},
{
type: "actions",
elements: alert.actions.map((action) => ({
type: "button",
text: {
type: "plain_text",
text: action.text
},
url: action.url,
style: "primary"
}))
}
]
};
}
if (!!this.trigger_meta.has_slack_body && !!this.trigger_meta.slack_body) {
let envSecrets = GetRequiredSecrets(this.trigger_meta.slack_body);
for (let i = 0; i < envSecrets.length; i++) {
const secret = envSecrets[i];
this.trigger_meta.slack_body = ReplaceAllOccurrences(this.trigger_meta.slack_body, secret.find, secret.replace);
}
}
}
async send(data) {
try {
const response = await fetch(this.url, {
method: this.method,
headers: this.headers,
body: JSON.stringify(this.transformData(data))
});
return response;
} catch (error) {
console.error("Error sending webhook", error);
return error;
}
}
transformData(alert) {
let view = variables(this.siteData, alert);
let slackTemplate = SlackJSONTemplate;
if (!!this.trigger_meta.has_slack_body && !!this.trigger_meta.slack_body) {
slackTemplate = this.trigger_meta.slack_body;
}
return Mustache.render(slackTemplate, view);
}
async send(data) {
try {
const response = await fetch(this.url, {
method: this.method,
headers: this.headers,
body: this.transformData(data),
});
if (!response.ok) {
throw new Error(`Error from slack`);
}
return response;
} catch (error) {
console.error("Error sending webhook", error);
return error;
}
}
}
export default Slack;
+1 -1
View File
@@ -1,6 +1,6 @@
// @ts-nocheck
import nodemailer from "nodemailer";
import { HashString } from "../controllers/controller.js";
import { HashString } from "../tool.js";
const transports = {};
export default function getSMTPTransport(meta) {
//convert meta to string and generate has id
+33
View File
@@ -0,0 +1,33 @@
// @ts-nocheck
export default function variables(siteData, data) {
let siteURL = siteData.siteURL;
let siteName = siteData.siteName;
if (siteURL.endsWith("/")) {
siteURL = siteURL.slice(0, -1);
}
if (!siteData.logo.startsWith("http")) {
siteData.logo = `${siteURL}${!!process.env.KENER_BASE_PATH ? process.env.KENER_BASE_PATH : ""}${siteData.logo}`;
}
let view = {
site_name: siteName,
logo_url: siteData.logo,
site_url: siteURL,
alert_name: data.alert_name,
status: data.status,
is_resolved: data.status === "RESOLVED",
is_triggered: data.status === "TRIGGERED",
description: data.description,
action_text: data.actions[0].text,
action_url: data.actions[0].url,
metric: data.details.metric,
severity: data.severity,
id: data.id,
current_value: data.details.current_value,
threshold: data.details.threshold,
source: data.source,
timestamp: data.timestamp,
};
return view;
}
+18 -4
View File
@@ -1,6 +1,10 @@
// @ts-nocheck
import { GetRequiredSecrets, ReplaceAllOccurrences } from "../tool.js";
import Mustache from "mustache";
import { WebhookJSONTemplate } from "../../anywhere.js";
import variables from "./variables.js";
import version from "../../version.js";
class Webhook {
url;
@@ -13,7 +17,7 @@ class Webhook {
constructor(trigger_meta, method, siteData, monitorData) {
const kenerHeader = {
"Content-Type": "application/json",
"User-Agent": "Kener/3.2.5",
"User-Agent": `Kener/${version()}`,
};
let headers = trigger_meta.headers;
this.trigger_meta = trigger_meta;
@@ -52,8 +56,13 @@ class Webhook {
}
transformData(data) {
if (!!!this.trigger_meta.has_webhook_body) return JSON.stringify(data);
if (!!!this.trigger_meta.webhook_body) return JSON.stringify(data);
let view = variables(this.siteData, data);
if (!!!this.trigger_meta.has_webhook_body || !!!this.trigger_meta.webhook_body) {
return Mustache.render(WebhookJSONTemplate, view);
}
//have to keep this for backward compatibility
let body = this.trigger_meta.webhook_body;
body = ReplaceAllOccurrences(body, "${id}", data.id);
body = ReplaceAllOccurrences(body, "${alert_name}", data.alert_name);
@@ -67,7 +76,9 @@ class Webhook {
body = ReplaceAllOccurrences(body, "${threshold}", data.details.threshold);
body = ReplaceAllOccurrences(body, "${action_text}", data.actions[0].text);
body = ReplaceAllOccurrences(body, "${action_url}", data.actions[0].url);
return body;
//have to keep this for backward compatibility
return Mustache.render(body, view);
}
type() {
@@ -81,6 +92,9 @@ class Webhook {
headers: this.headers,
body: this.transformData(data),
});
if (!response.ok) {
throw new Error(`Error from webhook`);
}
return response;
} catch (error) {
console.error("Error sending webhook", error);
+10 -17
View File
@@ -3,20 +3,9 @@ import axios from "axios";
import { GetRequiredSecrets, ReplaceAllOccurrences } from "../tool.js";
import { UP, DOWN, DEGRADED, REALTIME, TIMEOUT, ERROR, MANUAL } from "../constants.js";
import * as cheerio from "cheerio";
const defaultEval = `(async function (statusCode, responseTime, responseRaw, modules) {
let statusCodeShort = Math.floor(statusCode/100);
if(statusCode == 429 || (statusCodeShort >=2 && statusCodeShort <= 3)) {
return {
status: 'UP',
latency: responseTime,
}
}
return {
status: 'DOWN',
latency: responseTime,
}
})`;
import { DefaultAPIEval } from "../../anywhere.js";
import version from "../../version.js";
import https from "https";
class ApiCall {
monitor;
@@ -31,7 +20,7 @@ class ApiCall {
async execute() {
let axiosHeaders = {};
axiosHeaders["User-Agent"] = "Kener/" + "3.1.0";
axiosHeaders["User-Agent"] = `Kener/${version()}`;
axiosHeaders["Accept"] = "*/*";
let body = this.monitor.type_data.body;
@@ -44,10 +33,10 @@ class ApiCall {
}
let method = this.monitor.type_data.method;
let timeout = this.monitor.type_data.timeout || 5000;
let timeout = this.monitor.type_data.timeout || 10000;
let tag = this.monitor.tag;
let monitorEval = !!this.monitor.type_data.eval ? this.monitor.type_data.eval : defaultEval;
let monitorEval = !!this.monitor.type_data.eval ? this.monitor.type_data.eval : DefaultAPIEval;
for (let i = 0; i < this.envSecrets.length; i++) {
const secret = this.envSecrets[i];
@@ -82,6 +71,10 @@ class ApiCall {
transformResponse: (r) => r,
};
if (!!this.monitor.type_data.allowSelfSignedCert) {
options.httpsAgent = new https.Agent({ rejectUnauthorized: false });
}
if (!!body) {
options.data = body;
}
+4 -1
View File
@@ -1,6 +1,6 @@
// @ts-nocheck
import axios from "axios";
import { GetRequiredSecrets, ReplaceAllOccurrences, Wait } from "../tool.js";
import { GetRequiredSecrets, ReplaceAllOccurrences, Wait, GetMinuteStartNowTimestampUTC } from "../tool.js";
import { UP, DOWN, DEGRADED, REALTIME, TIMEOUT, ERROR, MANUAL } from "../constants.js";
import db from "../db/db.js";
@@ -29,6 +29,9 @@ class GroupCall {
}
async execute(startOfMinute) {
if (!!!startOfMinute) {
startOfMinute = GetMinuteStartNowTimestampUTC();
}
let tagArr = this.monitor.type_data.monitors.map((m) => m.tag);
return await waitForDataAndReturn(tagArr, startOfMinute, 500, this.monitor.type_data.timeout);
}
+2 -16
View File
@@ -2,21 +2,7 @@
import axios from "axios";
import { Ping } from "../ping.js";
import { UP, DOWN, DEGRADED, REALTIME, TIMEOUT, ERROR, MANUAL } from "../constants.js";
const defaultPingEval = `(async function (arrayOfPings) {
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
return acc + ping.latency;
}, 0);
let alive = arrayOfPings.reduce((acc, ping) => {
return acc && ping.alive;
}, true);
return {
status: alive ? 'UP' : 'DOWN',
latency: latencyTotal / arrayOfPings.length,
}
})`;
import { DefaultPingEval } from "../../anywhere.js";
class PingCall {
monitor;
@@ -27,7 +13,7 @@ class PingCall {
async execute() {
let hosts = this.monitor.type_data.hosts;
let pingEval = !!this.monitor.type_data.pingEval ? this.monitor.type_data.pingEval : defaultPingEval;
let pingEval = !!this.monitor.type_data.pingEval ? this.monitor.type_data.pingEval : DefaultPingEval;
let tag = this.monitor.tag;
if (hosts === undefined) {
console.log(
+2 -20
View File
@@ -2,25 +2,7 @@
import axios from "axios";
import { TCP } from "../ping.js";
import { UP, DOWN, DEGRADED, REALTIME, TIMEOUT, ERROR, MANUAL } from "../constants.js";
const defaultTcpEval = `(async function (arrayOfPings) {
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
return acc + ping.latency;
}, 0);
let alive = arrayOfPings.reduce((acc, ping) => {
if (ping.status === "open") {
return acc && true;
} else {
return false;
}
}, true);
return {
status: alive ? 'UP' : 'DOWN',
latency: latencyTotal / arrayOfPings.length,
}
})`;
import { DefaultTCPEval } from "../../anywhere.js";
class TcpCall {
monitor;
@@ -31,7 +13,7 @@ class TcpCall {
async execute() {
let hosts = this.monitor.type_data.hosts;
let tcpEval = !!this.monitor.type_data.tcpEval ? this.monitor.type_data.tcpEval : defaultTcpEval;
let tcpEval = !!this.monitor.type_data.tcpEval ? this.monitor.type_data.tcpEval : DefaultTCPEval;
let tag = this.monitor.tag;
if (hosts === undefined) {
+5 -19
View File
@@ -4,29 +4,17 @@ import figlet from "figlet";
import { Cron } from "croner";
import { Minuter } from "./cron-minute.js";
import db from "./db/db.js";
import { GetAllSiteData, GetMonitorsParsed, HashString } from "./controllers/controller.js";
import { GetAllSiteData, GetMonitorsParsed } from "./controllers/controller.js";
import { HashString } from "./tool.js";
import { fileURLToPath } from "url";
import { dirname, resolve } from "path";
import fs from "fs";
import version from "../version.js";
const jobs = [];
process.env.TZ = "UTC";
let isStartUP = true;
// Get the version from package.json
const getVersion = () => {
try {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packagePath = resolve(__dirname, "../../../package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
return packageJson.version;
} catch (error) {
console.error("Error reading version:", error);
return "unknown";
}
};
const scheduleCronJobs = async () => {
// Fetch and map all active monitors, creating a unique hash for each
const activeMonitors = (await GetMonitorsParsed({ status: "ACTIVE" })).map((monitor) => ({
@@ -85,15 +73,13 @@ async function Startup() {
mainJob.trigger();
const version = getVersion();
figlet("Kener v" + version, function (err, data) {
figlet("Kener v" + version(), function (err, data) {
if (err) {
console.log("Something went wrong...");
return;
}
console.log(data);
console.log(`Kener version ${version} is running!`);
console.log(`Kener version ${version()} is running!`);
});
}
+217
View File
@@ -0,0 +1,217 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<link rel="preload" as="image" href="{{logo_url}}" />
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
<!--$-->
</head>
<div
style="
display: none;
overflow: hidden;
line-height: 1px;
opacity: 0;
max-height: 0;
max-width: 0;
"
>
Reset your {{brand_name}} password
</div>
<body
style="
background-color: rgb(243, 244, 246);
font-family:
ui-sans-serif, system-ui, sans-serif, &quot;Apple Color Emoji&quot;,
&quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;;
"
>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
margin-left: auto;
margin-right: auto;
margin-top: 40px;
margin-bottom: 40px;
max-width: 600px;
border-radius: 0.25rem;
background-color: rgb(255, 255, 255);
padding: 20px;
"
>
<tbody>
<tr style="width: 100%">
<td>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="text-align: center"
>
<tbody>
<tr>
<td>
<img
alt="{{brand_name}} Logo"
height="50"
src="{{logo_url}}"
style="
margin-left: auto;
margin-right: auto;
margin-top: 20px;
margin-bottom: 20px;
display: block;
outline: none;
border: none;
text-decoration: none;
"
width="200"
/>
</td>
</tr>
</tbody>
</table>
<h1
style="
font-size: 24px;
font-weight: 700;
color: rgb(31, 41, 55);
text-align: center;
margin-top: 30px;
margin-bottom: 30px;
"
>
Password Reset Request
</h1>
<p
style="
font-size: 16px;
line-height: 24px;
color: rgb(75, 85, 99);
margin-bottom: 24px;
margin: 16px 0;
"
>
Hello,
</p>
<p
style="
font-size: 16px;
line-height: 24px;
color: rgb(75, 85, 99);
margin-bottom: 24px;
margin: 16px 0;
"
>
We received a request to reset your password for your {{brand_name}} account. If you
didn&#x27;t make this request, you can safely ignore this email.
</p>
<p
style="
font-size: 16px;
line-height: 24px;
color: rgb(75, 85, 99);
margin-bottom: 32px;
margin: 16px 0;
"
>
To reset your password, please click the button below:
</p>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="text-align: center; margin-bottom: 32px"
>
<tbody>
<tr>
<td>
<a
href="{{link}}"
style="
background-color: rgb(37, 99, 235);
color: rgb(255, 255, 255);
font-weight: 700;
padding-top: 12px;
padding-bottom: 12px;
padding-left: 24px;
padding-right: 24px;
border-radius: 0.25rem;
box-sizing: border-box;
line-height: 100%;
text-decoration: none;
display: inline-block;
max-width: 100%;
mso-padding-alt: 0px;
padding: 12px 24px 12px 24px;
"
target="_blank"
><span
><!--[if mso
]><i style="mso-font-width: 400%; mso-text-raise: 18" hidden
>&#8202;&#8202;&#8202;</i
><!
[endif]--></span
><span
style="
max-width: 100%;
display: inline-block;
line-height: 120%;
mso-padding-alt: 0px;
mso-text-raise: 9px;
"
>Reset Password</span
><span
><!--[if mso
]><i style="mso-font-width: 400%" hidden
>&#8202;&#8202;&#8202;&#8203;</i
><!
[endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<p
style="
font-size: 16px;
line-height: 24px;
color: rgb(75, 85, 99);
margin-bottom: 24px;
margin: 16px 0;
"
>
This password reset link will expire in 24 hours. If you need assistance, please
contact our support team.
</p>
<p
style="
font-size: 16px;
line-height: 24px;
color: rgb(75, 85, 99);
margin-bottom: 32px;
margin: 16px 0;
"
>
Thank you,<br />The {{brand_name}} Team
</p>
</td>
</tr>
</tbody>
</table>
<!--/$-->
</body>
</html>
@@ -0,0 +1,221 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<link rel="preload" as="image" href="{{logo_url}}" />
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
<!--$-->
</head>
<div
style="
display: none;
overflow: hidden;
line-height: 1px;
opacity: 0;
max-height: 0;
max-width: 0;
"
>
Verify your email address for {{brand_name}}
</div>
<body
style="
background-color: rgb(243, 244, 246);
font-family:
ui-sans-serif, system-ui, sans-serif, &quot;Apple Color Emoji&quot;,
&quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;;
"
>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
margin-left: auto;
margin-right: auto;
margin-top: 40px;
margin-bottom: 40px;
background-color: rgb(255, 255, 255);
padding: 20px;
border-radius: 8px;
box-shadow:
var(--tw-ring-offset-shadow, 0 0 #0000),
var(--tw-ring-shadow, 0 0 #0000),
0 1px 2px 0 rgb(0, 0, 0, 0.05);
max-width: 37.5em;
"
>
<tbody>
<tr style="width: 100%">
<td>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="margin-top: 32px"
>
<tbody>
<tr>
<td>
<img
alt="{{brand_name}}"
height="auto"
src="{{logo_url}}"
style="
margin-left: auto;
margin-right: auto;
display: block;
outline: none;
border: none;
text-decoration: none;
"
width="120"
/>
</td>
</tr>
</tbody>
</table>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="margin-top: 32px"
>
<tbody>
<tr>
<td>
<h1
style="
font-size: 24px;
font-weight: 700;
text-align: center;
color: rgb(31, 41, 55);
"
>
Verify Your Email Address
</h1>
<p
style="
font-size: 16px;
color: rgb(75, 85, 99);
margin-bottom: 24px;
line-height: 24px;
margin: 16px 0;
"
>
Thank you for signing up with
<!-- -->{{brand_name}}<!-- -->! To complete your registration and get started,
please verify your email address.
</p>
<p
style="
font-size: 16px;
color: rgb(75, 85, 99);
margin-bottom: 32px;
line-height: 24px;
margin: 16px 0;
"
>
Click the button below to verify your email address and activate your account:
</p>
<a
href="{{verification_url}}"
style="
box-sizing: border-box;
border-radius: 4px;
font-weight: 500;
text-decoration-line: none;
text-align: center;
color: rgb(255, 255, 255);
line-height: 100%;
text-decoration: none;
display: inline-block;
max-width: 100%;
mso-padding-alt: 0px;
background-color: #ed702d;
padding: 12px 24px 12px 24px;
"
target="_blank"
><span
><!--[if mso
]><i style="mso-font-width: 400%; mso-text-raise: 18" hidden
>&#8202;&#8202;&#8202;</i
><!
[endif]--></span
><span
style="
max-width: 100%;
display: inline-block;
line-height: 120%;
mso-padding-alt: 0px;
mso-text-raise: 9px;
"
>Verify Email Address</span
><span
><!--[if mso
]><i style="mso-font-width: 400%" hidden
>&#8202;&#8202;&#8202;&#8203;</i
><!
[endif]--></span
></a
>
<p
style="
font-size: 14px;
color: rgb(107, 114, 128);
margin-top: 32px;
line-height: 24px;
margin: 16px 0;
"
>
If you didn&#x27;t create an account with
<!-- -->{{brand_name}}<!-- -->, you can safely ignore this email.
</p>
<p
style="
font-size: 14px;
color: rgb(107, 114, 128);
line-height: 24px;
margin: 16px 0;
"
>
If the button above doesn&#x27;t work, copy and paste the following link into
your browser:
</p>
<p
style="
font-size: 14px;
color: rgb(107, 114, 128);
word-break: break-all;
line-height: 24px;
margin: 16px 0;
"
>
<a
href="{{verification_url}}"
style="color: rgb(37, 99, 235); text-decoration-line: underline"
target="_blank"
>{{verification_url}}</a
>
</p>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--/$-->
</body>
</html>
+7
View File
@@ -1,6 +1,7 @@
// @ts-nocheck
import { AllRecordTypes } from "./constants.js";
import knexOb from "../../../knexfile.js";
import crypto from "crypto";
import dotenv from "dotenv";
dotenv.config();
@@ -286,6 +287,11 @@ function GetDbType() {
//sqlite, postgresql, mysql
return knexOb.databaseType;
}
function HashString(str) {
const hash = crypto.createHash("sha256");
hash.update(str);
return hash.digest("hex");
}
export {
IsValidURL,
@@ -315,4 +321,5 @@ export {
Wait,
MaskString,
GetDbType,
HashString,
};
+64
View File
@@ -0,0 +1,64 @@
import { Component, Input, ElementRef, OnInit, OnDestroy, AfterViewInit } from '@angular/core';
@Component({
selector: 'app-embed-monitor',
template: `<div [id]="containerId"></div>`
})
export class EmbedMonitorComponent implements OnInit, OnDestroy, AfterViewInit {
@Input() monitor!: string;
@Input() theme: string = "light";
@Input() bgc: string = "transparent";
@Input() locale: string = "en";
containerId = `embed-container-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
private iframe: HTMLIFrameElement | null = null;
private messageListener!: (event: MessageEvent) => void;
constructor(private el: ElementRef) {}
ngOnInit() {}
ngAfterViewInit() {
this.createIframe();
}
ngOnChanges() {
this.createIframe();
}
createIframe() {
const container = this.el.nativeElement.querySelector(`#${this.containerId}`);
if (!this.monitor || !container) return;
// Remove previous iframe if it exists
if (this.iframe) {
this.iframe.remove();
}
this.iframe = document.createElement("iframe");
this.iframe.src = `${this.monitor}?theme=${this.theme}&bgc=${this.bgc}&locale=${this.locale}`;
this.iframe.width = "0%";
this.iframe.height = "0";
this.iframe.frameBorder = "0";
this.iframe.allowTransparency = true;
this.iframe.sandbox =
"allow-modals allow-forms allow-same-origin allow-scripts allow-popups allow-top-navigation-by-user-activation allow-downloads";
this.iframe.allow = "midi; geolocation; microphone; camera; display-capture; encrypted-media;";
container.appendChild(this.iframe);
this.messageListener = (event: MessageEvent) => {
if (event.data?.height) this.iframe!.height = event.data.height;
if (event.data?.width) this.iframe!.width = event.data.width;
};
window.addEventListener("message", this.messageListener);
}
ngOnDestroy() {
window.removeEventListener("message", this.messageListener);
if (this.iframe) {
this.iframe.remove();
}
}
}
+1
View File
@@ -0,0 +1 @@
<app-embed-monitor monitor="http://localhost:3000/embed/monitor-{selectedMonitor.value}" theme="{embedTheme}" bgc="{embedBgc.substr(1)}" locale="{embedLocale.value}"></app-embed-monitor>
+8
View File
@@ -0,0 +1,8 @@
<iframe
src="http://localhost:3000/embed/monitor-{selectedMonitor.value}?bgc={embedBgc.substr(1)}&locale={embedLocale.value}&theme={embedTheme}"
width="100%"
height="200"
allowfullscreen="allowfullscreen"
allowpaymentrequest
frameborder="0">
</iframe>
+1
View File
@@ -0,0 +1 @@
<script async src="http://localhost:3000/embed/monitor-{selectedMonitor.value}/js?bgc={embedBgc.substr(1)}&locale={embedLocale.value}&theme={embedTheme}&monitor=http://localhost:3000/embed/monitor-{selectedMonitor.value}"></script>
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useRef, useState } from "react";
const EmbedMonitor = ({ monitor, theme = "light", bgc = "transparent", locale = "en" }) => {
const containerRef = useRef(null);
const iframeRef = useRef(null);
const [uid] = useState(`embed-container-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`);
useEffect(() => {
if (!monitor || !containerRef.current) return;
const container = containerRef.current;
// Remove previous iframe if exists
if (iframeRef.current) {
iframeRef.current.remove();
}
// Create a new iframe
const iframe = document.createElement("iframe");
iframe.src = `${monitor}?theme=${theme}&bgc=${bgc}&locale=${locale}`;
iframe.width = "0%";
iframe.height = "0";
iframe.frameBorder = "0";
iframe.allowTransparency = true;
iframe.sandbox =
"allow-modals allow-forms allow-same-origin allow-scripts allow-popups allow-top-navigation-by-user-activation allow-downloads";
iframe.allow = "midi; geolocation; microphone; camera; display-capture; encrypted-media;";
container.appendChild(iframe);
iframeRef.current = iframe;
const setHeight = (data) => {
if (data.height !== undefined) {
iframe.height = data.height;
}
};
const setWidth = (data) => {
if (data.width !== undefined) {
iframe.width = data.width;
}
};
const messageListener = (event) => {
if (event.data && event.data.height !== undefined) {
setHeight(event.data);
}
if (event.data && event.data.width !== undefined) {
setWidth(event.data);
}
};
window.addEventListener("message", messageListener);
return () => {
window.removeEventListener("message", messageListener);
if (iframe) {
iframe.remove();
}
};
}, [monitor, theme, bgc, locale]);
return <div id={uid} ref={containerRef}></div>;
};
export default EmbedMonitor;
+12
View File
@@ -0,0 +1,12 @@
import EmbedMonitor from "./EmbedMonitor";
function App() {
return (
<div>
<h2>React Embed</h2>
<EmbedMonitor monitor="http://localhost:3000/embed/monitor-{selectedMonitor.value}" theme="{embedTheme}" bgc="{embedBgc.substr(1)}" locale="{embedLocale.value}"/>
</div>
);
}
export default App;
+91
View File
@@ -0,0 +1,91 @@
<script>
import { onMount, onDestroy, beforeUpdate } from "svelte";
export let monitor;
export let theme = "light";
export let bgc = "transparent";
export let locale = "en";
let iframe, listeners;
let containerId = `embed-container-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
let isMounted = false;
let prevMonitor, prevTheme, prevBgc, prevLocale;
onMount(() => {
if (!monitor) return;
isMounted = true;
prevMonitor = monitor;
prevTheme = theme;
prevBgc = bgc;
prevLocale = locale;
const container = document.getElementById(containerId);
if (!container) return;
const uid = "KENER_" + ~~(new Date().getTime() / 86400000);
const uriEmbedded = `${monitor}?theme=${theme}&bgc=${bgc}&locale=${locale}`;
iframe = document.createElement("iframe");
iframe.src = uriEmbedded;
iframe.id = uid;
iframe.width = "0%";
iframe.height = "0";
iframe.frameBorder = "0";
iframe.allowTransparency = true;
iframe.sandbox =
"allow-modals allow-forms allow-same-origin allow-scripts allow-popups allow-top-navigation-by-user-activation allow-downloads";
iframe.allow = "midi; geolocation; microphone; camera; display-capture; encrypted-media;";
container.appendChild(iframe);
const setHeight = (data) => {
if (data.height !== undefined) {
iframe.height = data.height;
}
};
const setWidth = (data) => {
if (data.width !== undefined) {
iframe.width = data.width;
}
};
listeners = (event) => {
if (event.data && event.data.height !== undefined) {
setHeight(event.data);
}
if (event.data && event.data.width !== undefined) {
setWidth(event.data);
}
};
window.addEventListener("message", listeners, false);
});
beforeUpdate(() => {
if (
isMounted &&
iframe &&
(monitor !== prevMonitor || theme !== prevTheme || bgc !== prevBgc || locale !== prevLocale)
) {
iframe.src = `${monitor}?theme=${theme}&bgc=${bgc}&locale=${locale}`;
// Update previous values
prevMonitor = monitor;
prevTheme = theme;
prevBgc = bgc;
prevLocale = locale;
}
});
onDestroy(() => {
if (iframe) {
window.removeEventListener("message", listeners);
iframe.remove();
}
});
</script>
<!-- Dynamic container with unique ID -->
<div id={containerId}></div>
+5
View File
@@ -0,0 +1,5 @@
<script>
import EmbedMonitor from "./EmbedMonitor.svelte";
</script>
<EmbedMonitor monitor="http://localhost:3000/embed/monitor-{selectedMonitor.value}" theme="{embedTheme}" bgc="{embedBgc.substr(1)}" locale="{embedLocale.value}"/>
+67
View File
@@ -0,0 +1,67 @@
<template>
<div :id="containerId" ref="containerRef"></div>
</template>
<script>
import { ref, watchEffect, onMounted, onBeforeUnmount } from "vue";
export default {
props: {
monitor: String,
theme: { type: String, default: "light" },
bgc: { type: String, default: "transparent" },
locale: { type: String, default: "en" }
},
setup(props) {
const containerRef = ref(null);
const iframeRef = ref(null);
const containerId = ref(`embed-container-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`);
let messageListener = null;
const createIframe = () => {
if (!props.monitor || !containerRef.value) return;
// Remove existing iframe if any
if (iframeRef.value) {
iframeRef.value.remove();
}
const iframe = document.createElement("iframe");
iframe.src = `${props.monitor}?theme=${props.theme}&bgc=${props.bgc}&locale=${props.locale}`;
iframe.width = "0%";
iframe.height = "0";
iframe.frameBorder = "0";
iframe.allowTransparency = true;
iframe.sandbox =
"allow-modals allow-forms allow-same-origin allow-scripts allow-popups allow-top-navigation-by-user-activation allow-downloads";
iframe.allow = "midi; geolocation; microphone; camera; display-capture; encrypted-media;";
containerRef.value.appendChild(iframe);
iframeRef.value = iframe;
messageListener = (event) => {
if (event.data?.height) iframe.height = event.data.height;
if (event.data?.width) iframe.width = event.data.width;
};
window.addEventListener("message", messageListener);
};
onMounted(createIframe);
watchEffect(() => {
createIframe();
});
onBeforeUnmount(() => {
window.removeEventListener("message", messageListener);
if (iframeRef.value) {
iframeRef.value.remove();
}
});
return { containerRef, containerId };
}
};
</script>
+14
View File
@@ -0,0 +1,14 @@
<template>
<div>
<h2>Vue Embed</h2>
<EmbedMonitor monitor="http://localhost:3000/embed/monitor-{selectedMonitor.value}" theme="{embedTheme}" bgc="{embedBgc.substr(1)}" locale="{embedLocale.value}"/>
</div>
</template>
<script>
import EmbedMonitor from "./EmbedMonitor.vue";
export default {
components: { EmbedMonitor }
};
</script>
+31
View File
@@ -0,0 +1,31 @@
import { fileURLToPath } from "url";
import { dirname, resolve } from "path";
import fs from "fs";
function getVersionUsingVite() {
try {
if (!!import.meta.env.PACKAGE_VERSION) {
return import.meta.env.PACKAGE_VERSION;
}
return null;
} catch (e) {
return null;
}
}
export default function version() {
let v = getVersionUsingVite();
if (!!v) {
return v;
} else {
try {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packagePath = resolve(__dirname, "../../package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
return packageJson.version;
} catch (e) {
return "0.0.0";
}
}
}
+2
View File
@@ -3,9 +3,11 @@
}
.copybtn .copy-btn {
transform: scale(1);
transition: transform 0.15s;
}
.copybtn .check-btn {
transform: scale(0);
transition: transform 0.15s;
}
.copybtn:focus .copy-btn {
transform: scale(0);
+4 -7
View File
@@ -2,6 +2,7 @@
import fs from "fs-extra";
import path from "path";
import { marked } from "marked";
import version from "$lib/version.js";
function extractMetadataAndContent(fileContent) {
// Regular expression to match metadata section
@@ -15,9 +16,7 @@ function extractMetadataAndContent(fileContent) {
if (match) {
// Extract metadata section and remaining content
const metadataLines = match[1]
.split("\n")
.filter((line) => line.trim() !== "");
const metadataLines = match[1].split("\n").filter((line) => line.trim() !== "");
result.content = fileContent.slice(match[0].length).trim(); // Remaining content after metadata
metadataLines.forEach((line) => {
@@ -40,10 +39,7 @@ export async function load({ params, route, url, cookies, request }) {
docFilePath = docFolderPath + `/${docFile}.md`;
}
const fileContents = await fs.readFileSync(docFilePath, "utf-8");
const siteStructure = await fs.readFileSync(
docFolderPath + "/structure.json",
"utf-8",
);
const siteStructure = await fs.readFileSync(docFolderPath + "/structure.json", "utf-8");
const { metadata, content } = extractMetadataAndContent(fileContents);
const selectedDoc = docFilePath.replace(docFolderPath, "");
@@ -54,5 +50,6 @@ export async function load({ params, route, url, cookies, request }) {
title: metadata.title || "Kener Docs",
description: metadata.description || "Kener Docs",
siteStructure: siteStructureJSON,
kenerVersion: version(),
};
}
+4 -1
View File
@@ -11,6 +11,7 @@
import Moon from "lucide-svelte/icons/moon";
import { onMount } from "svelte";
import { base } from "$app/paths";
import { page } from "$app/stores";
let defaultTheme = "light";
export let data;
let siteStructure = data.siteStructure;
@@ -110,7 +111,9 @@
<!-- Document Icon - Replace with your own logo -->
<img src="https://kener.ing/logo.png" class="h-8 w-8" alt="" />
<span class="text-xl font-medium">Kener Documentation</span>
<span class="me-2 rounded border px-2.5 py-0.5 text-xs font-medium"> 3.2.5 </span>
<span class="me-2 rounded border px-2.5 py-0.5 text-xs font-medium">
{$page.data.kenerVersion}
</span>
</a>
</div>
+22 -13
View File
@@ -1,7 +1,9 @@
<script>
import { onMount } from "svelte";
import { Button } from "$lib/components/ui/button";
import { ArrowLeft, ArrowRight } from "lucide-svelte";
import ArrowLeft from "lucide-svelte/icons/arrow-left";
import ArrowRight from "lucide-svelte/icons/arrow-right";
import { afterNavigate } from "$app/navigation";
export let data;
let subHeadings = [];
@@ -65,21 +67,28 @@
//function to find next and previous path
function findNextAndPreviousPath() {
let structure = data.siteStructure.sidebar;
for (let i = 0; i < structure.length; i++) {
let children = structure[i].children;
for (let j = 0; j < children.length; j++) {
if (children[j].file === data.docFilePath) {
if (j > 0) {
previousPath = children[j - 1];
}
if (j < children.length - 1) {
nextPath = children[j + 1];
}
break;
}
// Flatten the structure - combine all children arrays into one
const flattenedPages = [];
for (let i = 0; i < structure.length; i++) {
const section = structure[i];
if (section.children && Array.isArray(section.children)) {
flattenedPages.push(...section.children);
}
}
// Find current page index in the flattened array
const currentIndex = flattenedPages.findIndex((page) => page.file === data.docFilePath);
// Set previous and next paths if they exist
if (currentIndex > 0) {
previousPath = flattenedPages[currentIndex - 1];
}
if (currentIndex < flattenedPages.length - 1) {
nextPath = flattenedPages[currentIndex + 1];
}
}
afterNavigate(() => {
+71 -73
View File
@@ -1,76 +1,74 @@
<script>
import "../../app.postcss";
import "../../kener.css";
import Nav from "$lib/components/nav.svelte";
import { onMount } from "svelte";
import { base } from "$app/paths";
import { Button } from "$lib/components/ui/button";
import Sun from "lucide-svelte/icons/sun";
import Moon from "lucide-svelte/icons/moon";
import { Languages } from "lucide-svelte";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { setMode, mode, ModeWatcher } from "mode-watcher";
export let data;
import "../../app.postcss";
import "../../kener.css";
import Nav from "$lib/components/nav.svelte";
import { onMount } from "svelte";
import { base } from "$app/paths";
import { Button } from "$lib/components/ui/button";
import Sun from "lucide-svelte/icons/sun";
import Moon from "lucide-svelte/icons/moon";
import Languages from "lucide-svelte/icons/languages";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { setMode, mode, ModeWatcher } from "mode-watcher";
export let data;
let defaultLocaleKey = data.selectedLang;
let defaultTheme = data.site.theme;
const allLocales = data.site.i18n?.locales.filter((locale) => locale.selected === true);
let defaultLocaleKey = data.selectedLang;
let defaultTheme = data.site.theme;
const allLocales = data.site.i18n?.locales.filter((locale) => locale.selected === true);
function toggleMode() {
if ($mode === "light") {
setMode("dark");
} else {
setMode("light");
}
}
let defaultLocaleValue;
if (!allLocales) {
defaultLocaleValue = "English";
} else {
defaultLocaleValue = allLocales.find((locale) => locale.code === defaultLocaleKey).name;
}
/**
* @param {string} locale
*/
function setLanguage(locale) {
document.cookie = `localLang=${locale};max-age=${60 * 60 * 24 * 365 * 30}`;
if (locale === defaultLocaleKey) return;
defaultLocaleValue = allLocales[locale];
function toggleMode() {
if ($mode === "light") {
setMode("dark");
} else {
setMode("light");
}
}
let defaultLocaleValue;
if (!allLocales) {
defaultLocaleValue = "English";
} else {
defaultLocaleValue = allLocales.find((locale) => locale.code === defaultLocaleKey).name;
}
/**
* @param {string} locale
*/
function setLanguage(locale) {
document.cookie = `localLang=${locale};max-age=${60 * 60 * 24 * 365 * 30}`;
if (locale === defaultLocaleKey) return;
defaultLocaleValue = allLocales[locale];
}
location.reload();
}
let Analytics;
onMount(async () => {
let localTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (localTz != data.localTz) {
if (data.isBot === false) {
document.cookie = "localTz=" + localTz + ";max-age=" + 60 * 60 * 24 * 365 * 30;
location.reload();
}
}
if (defaultTheme != "none") {
setMode(defaultTheme);
}
//
});
let Analytics;
onMount(async () => {
let localTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (localTz != data.localTz) {
if (data.isBot === false) {
document.cookie = "localTz=" + localTz + ";max-age=" + 60 * 60 * 24 * 365 * 30;
location.reload();
}
}
if (defaultTheme != "none") {
setMode(defaultTheme);
}
//
});
</script>
<svelte:head>
<title>{data.site.title}</title>
{#if data.site.favicon && data.site.favicon[0] == "/"}
<link rel="icon" id="kener-app-favicon" href="{base}{data.site.favicon}" />
{:else if data.site.favicon}
<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}
<title>{data.site.title}</title>
{#if data.site.favicon && data.site.favicon[0] == "/"}
<link rel="icon" id="kener-app-favicon" href="{base}{data.site.favicon}" />
{:else if data.site.favicon}
<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}
</svelte:head>
<ModeWatcher />
<main
style="
style="
--font-family: {data.site.font.family};
--bg-custom: {data.bgc};
--up-color: {data.site.colors.UP};
@@ -78,17 +76,17 @@
--degraded-color: {data.site.colors.DEGRADED}
"
>
<div class="">
<slot />
</div>
<div class="">
<slot />
</div>
</main>
<style>
/* Apply the global font family using the CSS variable */
* {
font-family: var(--font-family);
}
main {
background-color: var(--bg-custom);
}
/* Apply the global font family using the CSS variable */
* {
font-family: var(--font-family);
}
main {
background-color: var(--bg-custom);
}
</style>
+2 -1
View File
@@ -8,7 +8,8 @@
import { Button } from "$lib/components/ui/button";
import Sun from "lucide-svelte/icons/sun";
import Moon from "lucide-svelte/icons/moon";
import { Languages, Globe } from "lucide-svelte";
import Languages from "lucide-svelte/icons/languages";
import Globe from "lucide-svelte/icons/globe";
import * as Popover from "$lib/components/ui/popover";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { analyticsEvent } from "$lib/boringOne";
+9
View File
@@ -3,6 +3,7 @@ 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";
function removeTags(str) {
if (str === null || str === "") return false;
else str = str.toString();
@@ -29,7 +30,9 @@ async function returnTypeOfMonitorsPageMeta(url) {
}
if (!!query.get("group")) {
const groupStart = performance.now();
let g = await GetMonitors({ status: "ACTIVE", tag: query.get("group") });
if (g.length > 0) {
group = g[0];
let typeData = JSON.parse(g[0].type_data);
@@ -39,14 +42,20 @@ async function returnTypeOfMonitorsPageMeta(url) {
}
}
const monitorStart = performance.now();
let monitors = await GetMonitors(filter);
return { monitors, pageType, group };
}
export async function load({ parent, url }) {
const totalLoadStart = performance.now();
const query = url.searchParams;
const metaStart = performance.now();
let { monitors, pageType, group } = await returnTypeOfMonitorsPageMeta(url);
const processStart = performance.now();
let hiddenGroupedMonitorsTags = [];
for (let i = 0; i < monitors.length; i++) {
if (pageType === "home" && monitors[i].monitor_type === "GROUP") {
+3 -1
View File
@@ -6,7 +6,9 @@
import { Badge } from "$lib/components/ui/badge";
import { l } from "$lib/i18n/client";
import { base } from "$app/paths";
import { ArrowRight, ChevronLeft, X } from "lucide-svelte";
import ArrowRight from "lucide-svelte/icons/arrow-right";
import ChevronLeft from "lucide-svelte/icons/chevron-left";
import X from "lucide-svelte/icons/x";
import { hotKeyAction, clickOutsideAction } from "svelte-legos";
import { onMount } from "svelte";
import ShareMenu from "$lib/components/shareMenu.svelte";
+24 -4
View File
@@ -1,14 +1,34 @@
// @ts-nocheck
import { GetMonitors } from "$lib/server/controllers/controller.js";
import { GetMonitors, GetLatestMonitoringData, GetLatestStatusActiveAll } from "$lib/server/controllers/controller.js";
import StatusColor from "$lib/color.js";
import { makeBadge } from "badge-maker";
import db from "$lib/server/db/db.js";
import { ErrorSvg } from "$lib/anywhere.js";
export async function GET({ params, setHeaders, url }) {
// @ts-ignore
let monitors = await GetMonitors({ status: "ACTIVE" });
const { tag } = monitors.find((monitor) => monitor.tag === params.tag);
const lastObj = await db.getLatestMonitoringData(tag);
let lastObj;
let activeTags = monitors.map((monitor) => monitor.tag);
if (params.tag == "_") {
lastObj = await GetLatestStatusActiveAll(activeTags);
} else {
if (monitors.length === 0) {
return new Response(ErrorSvg, {
headers: {
"Content-Type": "image/svg+xml",
},
});
}
let m = monitors.find((monitor) => monitor.tag === params.tag);
if (!m) {
return new Response(ErrorSvg, {
headers: {
"Content-Type": "image/svg+xml",
},
});
}
lastObj = await GetLatestMonitoringData(params.tag);
}
//read query params
const query = url.searchParams;
const animate = query.get("animate") || "";
@@ -1,37 +1,62 @@
// @ts-nocheck
import { GetMonitors } from "$lib/server/controllers/controller.js";
import {
GetMonitors,
GetLatestMonitoringData,
GetLatestStatusActiveAll,
GetSiteDataByKey,
} from "$lib/server/controllers/controller.js";
import StatusColor from "$lib/color.js";
import { makeBadge } from "badge-maker";
import db from "$lib/server/db/db.js";
import { ErrorSvg } from "$lib/anywhere.js";
export async function GET({ params, setHeaders, url }) {
// @ts-ignore
let monitors = await GetMonitors({ status: "ACTIVE" });
const { tag, name } = monitors.find((monitor) => monitor.tag === params.tag);
const lastObj = await db.getLatestMonitoringData(tag);
let lastObj;
let name;
let tag;
//read query params
let myColors = await StatusColor();
const query = url.searchParams;
const labelColor = query.get("labelColor") || "#333";
let label = query.get("label") || name;
const color = query.get("color") || myColors[lastObj.status].substr(1);
const style = query.get("style") || "flat";
const siteName = await GetSiteDataByKey("siteName");
let monitors = await GetMonitors({ status: "ACTIVE" });
let activeTags = monitors.map((monitor) => monitor.tag);
if (params.tag == "_") {
lastObj = await GetLatestStatusActiveAll(activeTags);
name = siteName;
tag = "";
} else {
if (monitors.length === 0) {
return new Response(ErrorSvg, {
headers: {
"Content-Type": "image/svg+xml",
},
});
}
let m = monitors.find((monitor) => monitor.tag === params.tag);
tag = m.tag;
name = m.name;
lastObj = await GetLatestMonitoringData(tag);
}
label = label.trim();
//read query params
let myColors = await StatusColor();
const query = url.searchParams;
const labelColor = query.get("labelColor") || "333";
let label = query.get("label") || name;
const color = query.get("color") || myColors[lastObj.status].substr(1);
const style = query.get("style") || "flat";
const format = {
label: label,
message: lastObj.status,
color: color,
labelColor: labelColor,
style: style
};
const svg = makeBadge(format);
label = label.trim();
return new Response(svg, {
headers: {
"Content-Type": "image/svg+xml"
}
});
const format = {
label: label,
message: lastObj.status,
color: color,
labelColor: labelColor,
style: style,
};
const svg = makeBadge(format);
return new Response(svg, {
headers: {
"Content-Type": "image/svg+xml",
},
});
}
@@ -2,18 +2,32 @@
import { ParseUptime, GetMinuteStartNowTimestampUTC } from "$lib/server/tool.js";
import { makeBadge } from "badge-maker";
import moment from "moment";
import db from "$lib/server/db/db.js";
import {
GetMonitors,
GetLastStatusBefore,
InterpolateData,
AggregateData,
GetMonitoringData,
GetMonitoringDataAll,
GetSiteDataByKey,
GetLastStatusBeforeAll,
} from "$lib/server/controllers/controller.js";
import { ErrorSvg } from "$lib/anywhere.js";
export async function GET({ params, url }) {
// @ts-ignore
let monitors = await GetMonitors({ status: "ACTIVE" });
const { name, tag, include_degraded_in_downtime } = monitors.find((monitor) => monitor.tag === params.tag);
if (monitors.length === 0) {
return new Response(ErrorSvg, {
headers: {
"Content-Type": "image/svg+xml",
},
});
}
let name, tag, include_degraded_in_downtime;
const siteName = await GetSiteDataByKey("siteName");
const query = url.searchParams;
let sinceLast = query.get("sinceLast");
if (sinceLast == undefined || isNaN(sinceLast) || sinceLast < 60) {
@@ -22,7 +36,7 @@ export async function GET({ params, url }) {
const rangeInSeconds = sinceLast;
const now = Math.floor(Date.now() / 1000);
const since = GetMinuteStartNowTimestampUTC() - rangeInSeconds;
let label = query.get("label") || name;
const hideDuration = query.get("hideDuration") === "true";
const duration = moment.duration(rangeInSeconds, "seconds");
@@ -35,10 +49,32 @@ export async function GET({ params, url }) {
formatted = `${duration.minutes()}m`;
}
let todayDataDb = await db.getMonitoringData(tag, since, now);
let anchorStatus = await GetLastStatusBefore(tag, since);
todayDataDb = InterpolateData(todayDataDb, since, anchorStatus, now);
let todayDataDb, anchorStatus;
if (params.tag == "_") {
name = siteName;
let activeTags = monitors.map((monitor) => monitor.tag);
//if anyone has include_degraded_in_downtime = "YES" then we will include degraded in downtime
let hasIncludeDowntime = monitors.find((monitor) => monitor.include_degraded_in_downtime === "YES");
include_degraded_in_downtime = hasIncludeDowntime ? "YES" : "NO";
todayDataDb = await GetMonitoringDataAll(activeTags, since, now);
anchorStatus = await GetLastStatusBeforeAll(activeTags, since);
} else {
const m = monitors.find((monitor) => monitor.tag === params.tag);
if (!!!m) {
return new Response(ErrorSvg, {
headers: {
"Content-Type": "image/svg+xml",
},
});
}
tag = m.tag;
name = m.name;
include_degraded_in_downtime = m.include_degraded_in_downtime;
todayDataDb = await GetMonitoringData(tag, since, now);
anchorStatus = await GetLastStatusBefore(tag, since);
}
todayDataDb = InterpolateData(todayDataDb, since, anchorStatus, now);
let calculatedData = AggregateData(todayDataDb);
let numerator = calculatedData.UPs + calculatedData.DEGRADEDs;
@@ -48,7 +84,7 @@ export async function GET({ params, url }) {
}
let uptime = ParseUptime(numerator, denominator) + "%";
let label = query.get("label") || name;
const labelColor = query.get("labelColor") || "#333";
const color = query.get("color") || "#0079FF";
const style = query.get("style") || "flat";
+1 -1
View File
@@ -5,5 +5,5 @@ import { base } from "$app/paths";
import { format } from "date-fns";
export async function load({ parent, url }) {
throw redirect(302, base + "/incidents/" + format(new Date(), "MMMM-yyyy"));
throw redirect(302, base + "/incidents/" + format(new Date(), "MMMM-yyyy"));
}
@@ -8,7 +8,7 @@ import {
} from "$lib/server/controllers/controller.js";
import { BeginningOfDay } from "$lib/server/tool.js";
import db from "$lib/server/db/db.js";
import { format, addMonths, parse, subMonths, getUnixTime, startOfMonth, endOfMonth } from "date-fns";
import { format, addDays, subDays, parse, getUnixTime, startOfMonth, endOfMonth } from "date-fns";
import { f } from "$lib/i18n/client";
export async function load({ parent, url, params, cookies }) {
@@ -16,17 +16,33 @@ export async function load({ parent, url, params, cookies }) {
const siteData = parentData.site;
let monitors = await GetMonitors({ status: "ACTIVE" });
const query = url.searchParams;
let todayStart = BeginningOfDay({ timeZone: parentData.localTz });
let tomorrowStart = todayStart + 86400;
let locale = GetLocaleFromCookie(parentData.site, cookies);
let month = params.month; //January-2021
if (!!!month) {
month = format(new Date(), "MMMM-yyyy");
}
let monthStart = startOfMonth(parse(month, "MMMM-yyyy", new Date()));
let monthEnd = endOfMonth(parse(month, "MMMM-yyyy", new Date()));
let nextMonth = addDays(monthEnd, 1);
let prevMonth = subDays(monthStart, 1);
let monthStartTs = getUnixTime(startOfMonth(parse(month, "MMMM-yyyy", new Date())));
// let xx = parse(month, "MMMM-yyyy", new Date());
let monthEndTs = getUnixTime(endOfMonth(parse(month, "MMMM-yyyy", new Date())));
let midnightMonthStartUTCTimestamp = getUnixTime(monthStart);
let midnightMonthEndUTCTimestamp = getUnixTime(monthEnd);
let midnightNextMonthUTCTimestamp = getUnixTime(nextMonth);
let midnightPrevMonthUTCTimestamp = getUnixTime(prevMonth);
let monthStartTs = BeginningOfDay({
date: new Date(midnightMonthStartUTCTimestamp * 1000),
timeZone: parentData.localTz,
});
let monthEndTs = BeginningOfDay({
date: new Date(midnightMonthEndUTCTimestamp * 1000),
timeZone: parentData.localTz,
});
let incidents = await GetIncidentsPage(monthStartTs, monthEndTs);
@@ -49,9 +65,12 @@ export async function load({ parent, url, params, cookies }) {
return {
incidents: incidents,
nextMonthName: f(addMonths(parse(month, "MMMM-yyyy", new Date()), 1), "MMMM-yyyy", "en", parentData.localTz),
prevMonthName: f(subMonths(parse(month, "MMMM-yyyy", new Date()), 1), "MMMM-yyyy", "en", parentData.localTz),
thisMonthName: f(parse(month, "MMMM-yyyy", new Date()), "MMMM-yyyy", "en", parentData.localTz),
nextMonthName: f(new Date(midnightNextMonthUTCTimestamp * 1000), "MMMM-yyyy", "en", parentData.localTz),
prevMonthName: f(new Date(midnightPrevMonthUTCTimestamp * 1000), "MMMM-yyyy", "en", parentData.localTz),
thisMonthName: f(monthEnd, "MMMM-yyyy", "en", parentData.localTz),
canonical: siteData.siteURL + "/incidents/" + month,
midnightNextMonthUTCTimestamp: midnightNextMonthUTCTimestamp,
midnightPrevMonthUTCTimestamp: midnightPrevMonthUTCTimestamp,
midnightMonthStartUTCTimestamp: midnightMonthStartUTCTimestamp + 86400,
};
}
@@ -2,7 +2,9 @@
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 ArrowRight from "lucide-svelte/icons/arrow-right";
import ArrowLeft from "lucide-svelte/icons/arrow-left";
import ChevronLeft from "lucide-svelte/icons/chevron-left";
import { base } from "$app/paths";
import { goto } from "$app/navigation";
import { l, f } from "$lib/i18n/client";
@@ -85,7 +87,7 @@
<div class="mesh mx-auto min-w-full max-w-[655px] rounded-md px-4 py-12 lg:flex lg:items-center">
<div class="blurry-bg mx-auto max-w-3xl text-center text-muted">
<h1 class=" text-5xl font-extrabold leading-tight">
{f(parse(data.thisMonthName, "MMMM-yyyy", new Date()), "MMMM, yyyy", selectedLang, $page.data.localTz)}
{f(new Date(data.midnightMonthStartUTCTimestamp * 1000), "MMMM, yyyy", selectedLang, $page.data.localTz)}
</h1>
<p class="mx-auto mt-4 max-w-xl font-medium sm:text-xl">
{l(data.lang, "Incident Updates")}
@@ -141,7 +143,7 @@
}}
>
<ArrowLeft class="arrow mr-2 h-4 w-4" />
{f(parse(data.prevMonthName, "MMMM-yyyy", new Date()), "MMMM, yyyy", selectedLang, $page.data.localTz)}
{f(new Date(data.midnightPrevMonthUTCTimestamp * 1000), "MMMM, yyyy", selectedLang, $page.data.localTz)}
</Button>
<Button
variant="secondary"
@@ -150,7 +152,7 @@
window.location.href = `${base}/incidents/${data.nextMonthName}`;
}}
>
{f(parse(data.nextMonthName, "MMMM-yyyy", new Date()), "MMMM, yyyy", selectedLang, $page.data.localTz)}
{f(new Date(data.midnightNextMonthUTCTimestamp * 1000), "MMMM, yyyy", selectedLang, $page.data.localTz)}
<ArrowRight class="arrow ml-2 h-4 w-4" />
</Button>
</div>
@@ -1,22 +1,19 @@
<script>
import "../../../../app.postcss";
import "../../../../kener.css";
import "../../../../manage.css";
import { base } from "$app/paths";
import { setMode, mode, ModeWatcher } from "mode-watcher";
setMode("dark");
import "../../../../app.postcss";
import "../../../../kener.css";
import "../../../../manage.css";
import { base } from "$app/paths";
import { setMode, mode, ModeWatcher } from "mode-watcher";
setMode("dark");
</script>
<ModeWatcher />
<svelte:head>
<meta name="description" content="Set up Kener" />
<meta name="robots" content="noindex" />
<link rel="icon" href="{base}/logo96.png" />
<meta name="description" content="Set up Kener" />
<meta name="robots" content="noindex" />
<link rel="icon" href="{base}/logo96.png" />
</svelte:head>
<main class="setup">
<!-- ========== HEADER ========== -->
<!-- ========== END HEADER ========== -->
<slot></slot>
<slot></slot>
</main>
@@ -1,117 +1,118 @@
<script>
import { base } from "$app/paths";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Alert from "$lib/components/ui/alert";
import { base } from "$app/paths";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Alert from "$lib/components/ui/alert";
let form = {
email: ""
};
export let data;
let isResendSet = data.isResendSet && data.isSecretSet;
if (!isResendSet && !data.isSMTPSet) {
data.view = "error";
data.error = `<p>Environment variables are not set. Read the documentation to set them. https://kener.ing/docs/environment-vars</p>
let form = {
email: ""
};
export let data;
let isResendSet = data.isResendSet && data.isSecretSet;
if (!isResendSet && !data.isSMTPSet) {
data.view = "error";
data.error = `<p>Environment variables are not set. Read the documentation to set them. https://kener.ing/docs/environment-vars</p>
<br/>
<p>Either Set RESEND_API_KEY, RESEND_SENDER_EMAIL <br>or<br> Set SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM_EMAIL</p>
<br/>
<p>If both are set then SMTP will take priority</p>`;
}
}
if (data.isSiteURLSet === false) {
data.view = "error";
data.error =
"Site URL is not set. Read the documentation to set it. https://kener.ing/docs/site";
}
if (data.isSiteURLSet === false) {
data.view = "error";
data.error = "Site URL is not set. Read the documentation to set it. https://kener.ing/docs/site";
}
</script>
<svelte:head>
<title>Forgot Password Kener</title>
<title>Forgot Password Kener</title>
</svelte:head>
<div class="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8">
<div class="sm:mx-auto sm:w-full sm:max-w-sm">
<img class="mx-auto h-10 w-auto" src="{base}/logo.png" alt="Your Company" />
<h2 class="mt-10 text-center text-2xl/9 font-bold tracking-tight">Reset Password</h2>
<p class="mt-4 text-center">Follow instructions to reset your password</p>
</div>
<div class="sm:mx-auto sm:w-full sm:max-w-sm">
<img class="mx-auto h-10 w-auto" src="{base}/logo.png" alt="Your Company" />
<h2 class="mt-10 text-center text-2xl/9 font-bold tracking-tight">Reset Password</h2>
<p class="mt-4 text-center">Follow instructions to reset your password</p>
</div>
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
{#if data.view == "error"}
<Alert.Root variant="destructive" class="my-4">
<Alert.Title>Error</Alert.Title>
<Alert.Description>{@html data.error}</Alert.Description>
</Alert.Root>
{/if}
{#if data.view == "forgot"}
<form class="space-y-6" action="{base}/manage/forgot/submit" method="POST">
<div>
<label for="email" class="block text-sm/6 font-medium">Email address</label>
<div class="mt-2">
<Input
bind:value={form.email}
type="email"
name="email"
id="form_email"
autocomplete="email"
placeholder="user@example.com"
required
/>
</div>
</div>
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
{#if data.view == "error"}
<Alert.Root variant="destructive" class="my-4">
<Alert.Title>Error</Alert.Title>
<Alert.Description>{@html data.error}</Alert.Description>
</Alert.Root>
{/if}
{#if data.view == "forgot"}
<form class="space-y-6" action="{base}/manage/forgot/submit" method="POST">
<div>
<label for="email" class="block text-sm/6 font-medium">Email address</label>
<div class="mt-2">
<Input
bind:value={form.email}
type="email"
name="email"
id="form_email"
autocomplete="email"
placeholder="user@example.com"
required
/>
</div>
</div>
<div>
<Button type="submit" class="w-full">Reset Password</Button>
</div>
</form>
{/if}
{#if data.view == "sent"}
<Alert.Root variant="success" class="my-4">
<Alert.Title>Success</Alert.Title>
<Alert.Description
>An email has been sent to {data.email} with instructions on how to reset your password.</Alert.Description
>
</Alert.Root>
{/if}
<div>
<Button type="submit" class="w-full">Reset Password</Button>
</div>
</form>
{/if}
{#if data.view == "sent"}
<Alert.Root variant="success" class="my-4">
<Alert.Title>Success</Alert.Title>
<Alert.Description class="flex flex-col gap-y-2">
<p>An email has been sent to {data.email} with instructions on how to reset your password.</p>
<p>
If you don't see the email, check other places it might be, like your junk, spam, social, or other folders.
</p>
<p>
Once you have reset your password, you can <a
class="font-semibold text-blue-500"
href="{base}/manage/signin">login</a
>
</p>
</Alert.Description>
</Alert.Root>
{/if}
{#if data.view == "token"}
<form class="space-y-6" action="{base}/manage/forgot/reset" method="POST">
<div>
<label for="password" class="block text-sm/6 font-medium">New password</label>
<div class="mt-2">
<Input
type="password"
name="password"
id="form_password"
autocomplete="new-password"
placeholder="********"
required
/>
</div>
<Input
type="hidden"
name="token"
id="form_password"
bind:value={data.token}
required
/>
</div>
{#if data.view == "token"}
<form class="space-y-6" action="{base}/manage/forgot/reset" method="POST">
<div>
<label for="password" class="block text-sm/6 font-medium">New password</label>
<div class="mt-2">
<Input
type="password"
name="password"
id="form_password"
autocomplete="new-password"
placeholder="********"
required
/>
</div>
<Input type="hidden" name="token" id="form_password" bind:value={data.token} required />
</div>
<div>
<Button type="submit" class="w-full">Confirm Password</Button>
</div>
</form>
{/if}
{#if data.view == "success"}
<Alert.Root variant="success" class="my-4">
<Alert.Title>Success</Alert.Title>
<Alert.Description
>Your password has been reset successfully. <a
class="font-semibold text-blue-500"
href="{base}/manage/signin">Login</a
> with you new password</Alert.Description
>
</Alert.Root>
{/if}
</div>
<div>
<Button type="submit" class="w-full">Confirm Password</Button>
</div>
</form>
{/if}
{#if data.view == "success"}
<Alert.Root variant="success" class="my-4">
<Alert.Title>Success</Alert.Title>
<Alert.Description
>Your password has been reset successfully. <a class="font-semibold text-blue-500" href="{base}/manage/signin"
>Login</a
> with you new password</Alert.Description
>
</Alert.Root>
{/if}
</div>
</div>
@@ -2,88 +2,56 @@
import { json, redirect } from "@sveltejs/kit";
import { base } from "$app/paths";
import db from "$lib/server/db/db.js";
import { Resend } from "resend";
import getSMTPTransport from "$lib/server/notification/smtps.js";
import { SendEmailWithTemplate, GetSiteLogoURL } from "$lib/server/controllers/controller.js";
import forgotPasswordTemplate from "$lib/server/templates/forgot_pass.html?raw";
import {
HashPassword,
GenerateSalt,
GenerateToken,
VerifyToken,
GetSMTPFromENV
HashPassword,
GenerateSalt,
GenerateToken,
VerifyToken,
GetSMTPFromENV,
} from "$lib/server/controllers/controller.js";
export async function POST({ request, cookies }) {
//read form post data email and password
const formdata = await request.formData();
const email = formdata.get("email");
const senderEmail = process.env.RESEND_SENDER_EMAIL;
const resendKey = process.env.RESEND_API_KEY;
const siteURL = await db.getSiteData("siteURL");
//read form post data email and password
const formdata = await request.formData();
const email = formdata.get("email");
const senderEmail = process.env.RESEND_SENDER_EMAIL;
const resendKey = process.env.RESEND_API_KEY;
const siteURL = await db.getSiteData("siteURL");
const siteName = await db.getSiteData("siteName");
const logo = await db.getSiteData("logo");
//check if any entry in user table is already there
let userDB = await db.getUserByEmail(email);
if (!!!userDB) {
let errorMessage = "User does not exist";
throw redirect(302, base + "/manage/forgot?view=sent&email=" + email);
}
//check if any entry in user table is already there
let userDB = await db.getUserByEmail(email);
if (!!!userDB) {
let errorMessage = "User does not exist";
throw redirect(302, base + "/manage/forgot?view=sent&email=" + email);
}
//generate token
let token = await GenerateToken({
email: email
});
//generate token
let token = await GenerateToken({
email: email,
});
//send email with link to reset password
let link = siteURL.value + base + "/manage/forgot?view=token&token=" + token;
let subject = "[Reset Password] Kener Reset password request";
let message = `
<!DOCTYPE html>
<html>
<head>
<title>Reset Password</title>
</head>
<body>
<p>Click on the link below to reset your password:</p>
<p><a href="${link}">Reset Password</a></p>
<p>You can also copy paste this link ${link}</p>
<p></p>This link will expire in 1 hour.</p>
<p>If you did not request a password reset, please ignore this email.</p>
</body>
</html>
`;
let emailText = `
//send email with link to reset password
let link = siteURL.value + base + "/manage/forgot?view=token&token=" + token;
let subject = "[Reset Password] Kener Reset password request";
let emailText = `
Click on the link below to reset your password:
${link}
This link will expire in 1 hour.
If you did not request a password reset, please ignore this email.
`;
let mail = {
from: senderEmail,
to: [email],
subject: subject,
text: emailText,
html: message
};
let smtpData = GetSMTPFromENV();
if (!!smtpData) {
const transporter = getSMTPTransport(smtpData);
const mailOptions = {
from: smtpData.smtp_from_email,
to: email,
subject: mail.subject,
html: mail.html,
text: mail.text
};
try {
await transporter.sendMail(mailOptions);
} catch (error) {
console.error("Error sending email via SMTP", error);
return;
}
} else {
const resend = new Resend(resendKey);
await resend.emails.send(mail);
}
let emailData = {
logo_url: await GetSiteLogoURL(siteURL.value, logo.value, base),
link: link,
brand_name: siteName.value,
};
throw redirect(302, base + "/manage/forgot?view=sent&email=" + email);
await SendEmailWithTemplate(forgotPasswordTemplate, emailData, email, subject, emailText);
throw redirect(302, base + "/manage/forgot?view=sent&email=" + email);
}
@@ -0,0 +1,40 @@
// @ts-nocheck
import {
GetActiveInvitationByToken,
UpdateUserData,
UpdateInvitationStatusToAccepted,
} from "$lib/server/controllers/controller.js";
import { INVITE_VERIFY_EMAIL } from "$lib/server/constants.js";
export async function load({ parent, url }) {
const query = url.searchParams;
const invitation_token = query.get("token");
let invite = await GetActiveInvitationByToken(invitation_token);
let header = "Error";
let message = "Invalid Invitation Token. The invitation token is invalid or expired.";
let error = true;
if (!!invite) {
if (invite.invitation_type === INVITE_VERIFY_EMAIL) {
let updateData = {
userID: invite.invited_user_id,
updateKey: "is_verified",
updateValue: 1,
};
try {
let updateResp = await UpdateUserData(updateData);
let updateInvitationResp = await UpdateInvitationStatusToAccepted(invitation_token);
error = false;
header = "Email Verified Successfully";
message = "Thanks for verifying your email";
} catch (e) {
console.error(e);
}
}
}
return {
error: error,
message: message,
header: header,
};
}
@@ -0,0 +1,34 @@
<script>
import { Button } from "$lib/components/ui/button";
import { base } from "$app/paths";
export let data;
</script>
<svelte:head>
<title>Accept Invitation from Kener</title>
</svelte:head>
<div class="mx-auto mt-32 flex max-w-[475px] flex-col gap-y-4 text-center">
{#if data.error}
<picture class="mx-auto">
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f6a8/512.webp" type="image/webp" />
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f6a8/512.gif" alt="🚨" width="32" height="32" />
</picture>
{:else}
<picture class="mx-auto">
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f38a/512.webp" type="image/webp" />
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f38a/512.gif" alt="🎊" width="32" height="32" />
</picture>
{/if}
<h2 class="text-xl">
{data.header}
</h2>
<p class="text-sm">
{data.message}
</p>
{#if data.error}
<Button href="{base}/" class="mt-4 ">Go back</Button>
{:else}
<Button href="{base}/manage/app/site" class="mt-4 ">Go to Dashboard</Button>
{/if}
</div>
@@ -2,7 +2,13 @@
import { json, redirect } from "@sveltejs/kit";
import { base } from "$app/paths";
import db from "$lib/server/db/db.js";
import { HashPassword, GenerateSalt, GenerateToken, CookieConfig } from "$lib/server/controllers/controller.js";
import {
HashPassword,
GenerateSalt,
GenerateToken,
CookieConfig,
ValidatePassword,
} from "$lib/server/controllers/controller.js";
//function to validate a strong password
/**
@@ -12,16 +18,10 @@ import { HashPassword, GenerateSalt, GenerateToken, CookieConfig } from "$lib/se
* - Contains at least one uppercase letter.
* - Contains at least one letter (either lowercase or uppercase).
* - Has a minimum length of 8 characters.
*
* @param {string} password - The password to validate.
* @returns {boolean} - Returns true if the password meets the criteria, otherwise false.
*/
function validatePassword(password) {
return /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).{8,}$/.test(password);
}
export async function POST({ request, cookies }) {
//read form post data email and passowrd
//read form post data email and password
const formdata = await request.formData();
const email = formdata.get("email");
const password = formdata.get("password");
@@ -34,7 +34,7 @@ export async function POST({ request, cookies }) {
throw redirect(302, base + "/manage/setup?error=" + errorMessage);
}
//validate password
if (!validatePassword(password)) {
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 + "/manage/setup?error=" + errorMessage);
@@ -1,12 +1,10 @@
// @ts-nocheck
import {
GetAllSiteData,
VerifyToken,
} from "$lib/server/controllers/controller.js";
import { GetAllSiteData, VerifyToken, IsLoggedInSession } from "$lib/server/controllers/controller.js";
import { redirect } from "@sveltejs/kit";
import { base } from "$app/paths";
import { MaskString } from "$lib/server/tool.js";
import db from "$lib/server/db/db.js";
import version from "$lib/version.js";
//write a function to mask a string, just have last 4 characters visible
export async function load({ params, route, url, cookies, request }) {
@@ -15,29 +13,19 @@ export async function load({ params, route, url, cookies, request }) {
if (process.env.KENER_SECRET_KEY === undefined) {
throw redirect(302, base + "/manage/setup");
}
let tokenData = cookies.get("kener-user");
if (!!!tokenData) {
//redirect to signin page if user is not authenticated
throw redirect(302, base + "/manage/signin");
let userDB = null;
let isLoggedIn = await IsLoggedInSession(cookies);
if (!!isLoggedIn.error) {
throw redirect(302, base + isLoggedIn.location);
}
//get user by email
let tokenUser = await VerifyToken(tokenData);
if (!!!tokenUser) {
//redirect to signin page if user is not authenticated
throw redirect(302, base + "/manage/signin/logout");
}
let userDB = await db.getUserByEmail(tokenUser.email);
if (!!!userDB) {
//redirect to signin page if user is not authenticated
throw redirect(302, base + "/manage/signin");
}
userDB = isLoggedIn.user;
return {
siteData,
KENER_SECRET_KEY: !!process.env.KENER_SECRET_KEY
? MaskString(process.env.KENER_SECRET_KEY)
: "",
KENER_SECRET_KEY: !!process.env.KENER_SECRET_KEY ? MaskString(process.env.KENER_SECRET_KEY) : "",
user: userDB,
kenerVersion: version(),
};
}

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