Compare commits

..
Author SHA1 Message Date
charlesgauthereau c92e671622 chore(release): 1.1.12-rc.4 2026-01-11 14:19:49 +01:00
charlesgauthereau 2bd25353da chore(release): 1.1.12-rc.3 2026-01-11 14:13:58 +01:00
charlesgauthereau e1bee19c18 fix(ci): docker.yml 2026-01-11 14:13:51 +01:00
charlesgauthereau 0ec49af72a chore(release): 1.1.12-rc.2 2026-01-11 14:12:43 +01:00
charlesgauthereau 6d4eb836ab fix(ci): working on github actions to add new docker repository 2026-01-11 14:12:40 +01:00
charlesgauthereau 0c3cfecb1d chore(release): 1.1.12-rc.1 2026-01-11 13:54:35 +01:00
charlesgauthereau f9186e7354 fix: overflow in agent-card.tsx 2026-01-11 13:54:22 +01:00
charlesgauthereau bc603a08cf fix: Remove card in some forms. 2026-01-11 13:52:02 +01:00
charlesgauthereau 077aada056 chore(release): 1.1.11-rc.2 2026-01-11 13:36:57 +01:00
charlesgauthereau 4ed5f9872d fix: delete backup crash if no file. 2026-01-11 13:36:11 +01:00
charlesgauthereau 2d6be20b30 fix: improving organization-form.tsx UX/UI and removed the card. 2026-01-11 12:41:39 +01:00
charlesgauthereau be8f3bd98c fix: organization settings page responsive on mobile. 2026-01-11 12:37:57 +01:00
charlesgauthereau a3a20bee4a fix: agent-card.tsx overflow on mobile. 2026-01-11 12:23:32 +01:00
charlesgauthereau 09a1e75acf fix: notifier-card.tsx overflow on mobile. 2026-01-11 12:12:14 +01:00
charlesgauthereau ce6bdd01ef fix: overflow mobile view on the notification provider card in detabase details page. 2026-01-11 11:57:18 +01:00
charlesgauthereau a03a1d635f fix: responsive in project details. 2026-01-11 10:59:42 +01:00
charlesgauthereau 419b6d4891 chore(release): 1.1.11-rc.1 2026-01-09 19:16:17 +01:00
charlesgauthereau b4840318d5 fix: database page Backup button and route api. 2026-01-09 19:15:27 +01:00
Théo LAGACHE bf81f437d1 chore(release): 1.1.10 2026-01-07 15:29:53 +01:00
Théo LAGACHEandGitHub 81a4d33df1 Merge pull request #7 from Portabase/feat/webhooks
Feat/webhooks
2026-01-07 15:29:30 +01:00
Théo LAGACHE baab364b56 chore(release): 1.1.10-rc.2 2026-01-07 15:00:21 +01:00
Théo LAGACHE 585ee041ec ui fixes 2026-01-07 15:00:11 +01:00
Théo LAGACHE da3d4350cc chore(release): 1.1.10-rc.1 2026-01-07 13:33:42 +01:00
Théo LAGACHE df9390847f add: new notifiers and ui fixes 2026-01-07 13:33:23 +01:00
Théo LAGACHE 2a1acbfde8 chore(release): 1.1.9 2026-01-07 09:50:42 +01:00
Théo LAGACHEandGitHub 16ef8507b0 Merge pull request #6 from Portabase/readme
Readme
2026-01-07 09:48:56 +01:00
Théo LAGACHE df5d4e336c update: release 2026-01-07 09:46:30 +01:00
Théo LAGACHE ad2015329b chore(release): 1.1.9-rc6 2026-01-06 16:35:47 +01:00
Théo LAGACHE 4cc4bed785 chore(release): 1.1.9-rc5 2026-01-06 16:35:44 +01:00
Théo LAGACHE 6c506e6907 chore(release): 1.1.9-rc5 2026-01-06 16:25:12 +01:00
Théo LAGACHE 977f9344c7 chore(release): 1.1.9-rc4 2026-01-06 16:23:09 +01:00
Théo LAGACHE 2adb2f3b68 chore(release): 1.1.9-rc3 2026-01-06 16:21:06 +01:00
Théo LAGACHE 40a6e9e69f fix 2026-01-06 16:12:04 +01:00
Théo LAGACHE e1d1a3fb9a fix(ci): grant necessary permissions for docker publish workflow 2026-01-06 15:37:19 +01:00
Théo LAGACHE 75120c0a56 fix 2026-01-06 15:33:06 +01:00
Théo LAGACHE 33f1ef3bb3 ci: restore release candidate workflow and use reusable actions 2026-01-06 15:10:28 +01:00
Théo LAGACHE b6e742333c update: README.md 2026-01-06 13:26:52 +01:00
Théo LAGACHE 9f8c66fc20 update readme.md 2026-01-06 12:11:45 +01:00
charlesgauthereau 3b93257890 [Release] - 1.1.8 2026-01-05 19:41:53 +01:00
charlesgauthereau 1104e2a074 [FIX] (profile) - error in local upload (#4) 2026-01-05 19:39:59 +01:00
Théo LAGACHE 3e950652e8 update readme.md 2026-01-05 15:04:28 +01:00
charlesgauthereau 98e40466a0 [FIX] (agent) - order by createdAt 2026-01-04 21:24:06 +01:00
charlesgauthereau 324d80a735 [FIX] (backup) - change in extension file 2026-01-04 19:40:08 +01:00
charlesgauthereau 6770be63b6 [FIX] - adding fileSize in upload-backup.action.ts 2026-01-03 01:06:13 +01:00
98 changed files with 22231 additions and 15975 deletions
+1 -1
View File
@@ -34,4 +34,4 @@ S3_USE_SSL=true
STORAGE_TYPE=local
# Retention
RETENTION_CRON="* * * * *"
RETENTION_CRON="* * * * *"
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+155
View File
@@ -0,0 +1,155 @@
#name: Docker Publish
#
#on:
# workflow_call:
# inputs:
# image_name:
# required: false
# type: string
# default: 'solucetechnologies/portabase'
# add_latest:
# required: false
# type: boolean
# default: false
# target:
# required: false
# type: string
# default: 'prod'
# dockerfile:
# required: false
# type: string
# default: './docker/dockerfile/Dockerfile'
# secrets:
# DOCKER_USERNAME:
# required: true
# DOCKER_PASSWORD:
# required: true
#
#jobs:
# build-and-push:
# runs-on: ubuntu-latest
# permissions:
# packages: write
# contents: read
# steps:
# - name: Check out the repo
# uses: actions/checkout@v4
# with:
# fetch-depth: 0
#
# - name: Log in to Docker Hub
# uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
# with:
# username: ${{ secrets.DOCKER_USERNAME }}
# password: ${{ secrets.DOCKER_PASSWORD }}
#
# - name: Set tags
# id: set-tags
# run: |
# REF_NAME=${GITHUB_REF#refs/tags/}
# TAGS="${{ inputs.image_name }}:$REF_NAME"
# if [[ "${{ inputs.add_latest }}" == "true" ]]; then
# TAGS="$TAGS,${{ inputs.image_name }}:latest"
# fi
# echo "tags=$TAGS" >> $GITHUB_OUTPUT
#
# - name: Build and push Docker image
# uses: docker/build-push-action@v6
# with:
# context: .
# file: ${{ inputs.dockerfile }}
# push: true
# tags: ${{ steps.set-tags.outputs.tags }}
# target: ${{ inputs.target }}
name: Docker Publish
on:
workflow_call:
inputs:
image_name:
required: false
type: string
default: 'solucetechnologies/portabase'
image_name2:
required: false
type: string
default: 'portabase/portabase'
add_latest:
required: false
type: boolean
default: false
target:
required: false
type: string
default: 'prod'
dockerfile:
required: false
type: string
default: './docker/dockerfile/Dockerfile'
secrets:
DOCKER_USERNAME:
required: true
DOCKER_PASSWORD:
required: true
DOCKER_USERNAME_2:
required: true
DOCKER_PASSWORD_2:
required: true
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set tags
id: set-tags
run: |
REF_NAME=${GITHUB_REF#refs/tags/}
TAGS="${{ inputs.image_name }}:$REF_NAME"
TAGS2="${{ inputs.image_name2 }}:$REF_NAME"
if [[ "${{ inputs.add_latest }}" == "true" ]]; then
TAGS="$TAGS,${{ inputs.image_name }}:latest"
fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT
echo "tags2=$TAGS2" >> $GITHUB_OUTPUT
- name: Log in to Docker Hub account 1
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push to solucetechnologies/portabase
uses: docker/build-push-action@v6
with:
context: .
file: ${{ inputs.dockerfile }}
push: true
tags: ${{ steps.set-tags.outputs.tags }}
target: ${{ inputs.target }}
- name: Log in to Docker Hub account 2
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME_2 }}
password: ${{ secrets.DOCKER_PASSWORD_2 }}
- name: Build and push to portabase/portabase
uses: docker/build-push-action@v6
with:
context: .
file: ${{ inputs.dockerfile }}
push: true
tags: ${{ steps.set-tags.outputs.tags2 }}
target: ${{ inputs.target }}
+128
View File
@@ -0,0 +1,128 @@
name: GitHub Release
on:
workflow_call:
inputs:
prerelease:
required: false
type: boolean
default: false
make_latest:
required: false
type: boolean
default: false
discord_title:
required: true
type: string
discord_color:
required: true
type: number
discord_footer:
required: true
type: string
secrets:
DISCORD_WEBHOOK:
required: true
GH_TOKEN:
required: true
jobs:
create-release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Check out the repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build Changelog
id: build_changelog
uses: mikepenz/release-changelog-builder-action@v5
with:
mode: "COMMIT"
configurationJson: |
{
"template": "#{{CHANGELOG}}",
"categories": [
{
"title": "## Feature",
"labels": ["feat", "feature"]
},
{
"title": "## Fix",
"labels": ["fix", "bug"]
},
{
"title": "## Other",
"labels": []
}
],
"label_extractor": [
{
"pattern": "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)",
"on_property": "title",
"target": "$1"
}
]
}
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: false
body: ${{ steps.build_changelog.outputs.changelog }}
prerelease: ${{ inputs.prerelease }}
make_latest: ${{ inputs.make_latest }}
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
- name: Send Discord Notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
RELEASE_INFO=$(gh release view "${{ github.ref_name }}" -R ${{ github.repository }} --json name,url,body,author)
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name)
if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ github.ref_name }}"; fi
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url)
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body)
AUTHOR_NAME="Portabase"
AUTHOR_ICON="https://github.com/Portabase.png"
PAYLOAD=$(jq -n \
--arg title "$RELEASE_TITLE" \
--arg description "$RELEASE_BODY" \
--arg url "$RELEASE_URL" \
--arg author "$AUTHOR_NAME" \
--arg icon "$AUTHOR_ICON" \
--arg discord_title "${{ inputs.discord_title }}" \
--arg discord_footer "${{ inputs.discord_footer }}" \
--argjson discord_color ${{ inputs.discord_color }} \
'{
content: $discord_title,
embeds: [{
title: $title,
url: $url,
description: $description,
color: $discord_color,
author: {
name: $author,
icon_url: $icon
},
footer: {
text: $discord_footer
}
}]
}'
)
curl -H "Content-Type: application/json" \
-d "$PAYLOAD" \
"$DISCORD_WEBHOOK"
+34
View File
@@ -0,0 +1,34 @@
name: Publish Docker image for release candidate
on:
push:
tags:
- '*-rc*'
permissions:
contents: write
packages: write
jobs:
docker_publish:
uses: ./.github/workflows/docker.yml
with:
add_latest: false
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_USERNAME_2: ${{ secrets.DOCKER_USERNAME_2 }}
DOCKER_PASSWORD_2: ${{ secrets.DOCKER_PASSWORD_2 }}
github_release:
needs: docker_publish
uses: ./.github/workflows/github.yml
with:
prerelease: true
make_latest: false
discord_title: "||@release-dashboard|| New release candidate published"
discord_color: 16776960
discord_footer: "Portabase"
secrets:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+29 -44
View File
@@ -1,50 +1,35 @@
name: Publish Docker image for release
on:
release:
types: [published]
push:
tags:
- '*.*.*'
- '!*-*'
permissions:
contents: write
packages: write
jobs:
push_to_registry:
name: Push Docker image to Docker Hub
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
attestations: write
id-token: write
steps:
- name: Check out the repo
uses: actions/checkout@v4
docker_publish:
uses: ./.github/workflows/docker.yml
with:
add_latest: true
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_USERNAME_2: ${{ secrets.DOCKER_USERNAME_2 }}
DOCKER_PASSWORD_2: ${{ secrets.DOCKER_PASSWORD_2 }}
- name: Log in to Docker Hub
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: solucetechnologies/portabase
- name: Set tags
id: set-tags
run: |
echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
TAGS="solucetechnologies/portabase:${GITHUB_REF#refs/tags/}"
if [ "${{ github.event.release.target_commitish }}" = "main" ]; then
TAGS="$TAGS,solucetechnologies/portabase:latest"
fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/dockerfile/Dockerfile
push: true
tags: ${{ steps.set-tags.outputs.tags }}
target: prod
github_release:
needs: docker_publish
uses: ./.github/workflows/github.yml
with:
prerelease: false
make_latest: true
discord_title: "||@release-dashboard|| New release published"
discord_color: 5814783
discord_footer: "Portabase"
secrets:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Binary file not shown.
-2
View File
@@ -1,2 +0,0 @@
nodeLinker: node-modules
+2 -2
View File
@@ -26,5 +26,5 @@ keywords:
- web-ui
- agent
license: Apache-2.0
version: 1.1.6
date-released: "2026-01-01"
version: 1.1.12-rc.4
date-released: "2026-01-11"
+22 -249
View File
@@ -1,21 +1,16 @@
<br />
<div align="center">
<a href="https://portabase.io">
<img src="/public/images/logo.png" alt="Logo" width="80" height="80">
<img src="/.github/assets/logo.png" alt="Logo" width="80" height="80">
</a>
<h3 align="center">Portabase</h3>
<p>
Take full control of your databases with Portabase — the self-hosted, open-source platform
for automated backup, restoration, and operational management. Powered by the <strong>Portabase Agent</strong>,
every database in your infrastructure can be monitored, backed up, and managed in real time,
with zero reliance on third-party services.
</p>
<p>
Secure, lightweight, and deployable anywhere — on Docker, Kubernetes, or directly on your servers.
Designed for teams, DevOps, and enterprises who demand control, reliability, and automation at scale.
<p align="center" style="margin-top: 20px; font-style: italic;">
<i>Portabase is a tool designed to simplify the backup and restoration of your database instances. It integrates seamlessly with <a href="https://github.com/Portabase/agent">Portabase agents</a> for managing operations securely and efficiently.</i>
</p>
[![License: Apache](https://img.shields.io/badge/License-apache-yellow.svg)](LICENSE)
[![Docker Pulls](https://img.shields.io/docker/pulls/solucetechnologies/portabase?color=brightgreen)](https://hub.docker.com/r/solucetechnologies/portabase)
[![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](https://github.com/Portabase/portabase)
@@ -27,11 +22,18 @@
[![Open Source](https://img.shields.io/badge/open%20source-❤️-red)](https://github.com/Portabase/portabase)
[![NextJS][NextJS]][NextJS-url]
[![BetterAuth][BetterAuth]][BetterAuth-url]
[![Drizzle][Drizzle]][Drizzle-url]
[![ShadcnUI][ShadcnUI]][ShadcnUI-url]
[![Docker][Docker]][Docker-url]
<p>
<strong>
<a href="https://portabase.io">Documentation</a> •
<a href="https://portabase.io">Website</a> •
<a href="https://portabase.io/docs">Documentation</a> •
<a href="https://www.youtube.com/watch?v=hvLbX5LN1UE">Demo</a> •
<a href="#installation">Installation</a> •
<a href="https://portabase.io/docs/dashboard/setup">Installation</a> •
<a href="https://github.com/Portabase/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a> •
<a href="https://github.com/Portabase/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
</strong>
@@ -42,257 +44,28 @@
</div>
---
## Installation
## ✨ About The Project
You have 4 ways to install Portabase:
**Portabase** is a server dashboard tool designed to simplify the backup and restoration of your database instances. It
integrates seamlessly with [Portabase agents](https://github.com/Portabase/agent-portabase) for managing operations
securely and efficiently.
### 🔧 Built With
- [![NextJS][NextJS]][NextJS-url]
- [![Drizzle][Drizzle]][Drizzle-url]
- [![ShadcnUI][ShadcnUI]][ShadcnUI-url]
- [![BetterAuth][BetterAuth]][BetterAuth-url]
- [![Docker][Docker]][Docker-url]
---
## 📦 Features
### 🗄️ Supported databases
- PostgreSQL
- MySQL
- MariaDB
### ⏱️ Scheduled backups
- Cron-based scheduling for full control
- Manual trigger support for on-demand backups
### 💾 Storage backends
- On-premise storage: Backups are stored directly on your VPS or server
- Cloud storage: S3-compatible backends
supported ([documentation](https://portabase.io/docs/dashboard/advanced/storage/s3))
- Full data ownership: No third-party access — your data stays under your control
### 🔔 Smart notifications
- Multi-channel delivery: Email, Slack, Discord, webhooks
- Real-time alerts: Immediate feedback on success and failure
- Custom alert policies: Database-level notification rules
- Team-ready: Designed for DevOps, on-call, and incident workflows
### 👥 Built for team environments
- Workspaces: Organize databases, notification channels, and storage backends by organization and project
- Access control: Fine-grained, role-based permissions on all resources
- Role management: Member, admin, and owner roles at both system and organization levels
### 🐳 Self-hosted & secure
- Containerized deployment: Docker-based setup for predictable installation and operations
- Privacy by design: All data remains within your own infrastructure
- Open source: Apache 2.0 licensed — fully auditable codebase
### 🤖 Portabase Agent ([details](https://github.com/Portabase/agent-portabase))
- Headless architecture: Runs locally on your infrastructure to manage backups and database operations
- Multi-target support: Single agent can connect to multiple databases across different servers
- Lightweight & efficient: Minimal resource footprint while providing full operational control
- Secure communication: Encrypted channels between agent and central dashboard
---
## 🚀 Getting Started
### Installation
You have 3 ways to install **Portabase**:
- Automated CLI (recommended) - [details](https://portabase.io/docs/cli)
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup)
- Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli)
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker)
- Kubernetes with Helm (soon)
- Development setup - [details](https://portabase.io/docs/dashboard/setup#development)
Ensure Docker is installed on your machine before getting started.
**Ensure Docker is installed on your machine before getting started.**
### Docker Compose Setup
Create a `docker-compose.yml` file with the following configuration:
```yaml
name: portabase
services:
portabase:
image: solucetechnologies/portabase:latest
env_file:
- .env
ports:
- '8887:80'
environment:
- TZ="Europe/Paris"
volumes:
- portabase-private:/app/private
depends_on:
db:
condition: service_healthy
container_name: portabase-app
db:
image: postgres:17-alpine
ports:
- "5433:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=<your_database>
- POSTGRES_USER=<database_user>
- POSTGRES_PASSWORD=<database_password>
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U <database_user> -d <your_database>" ]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres-data:
portabase-private:
```
Then run:
```bash
docker compose up -d
```
If you use reverse proxy like
Traefik : [Check this link](https://portabase.io/docs/dashboard/advanced/reverse-proxy)
#### Environment Variables
```yml
# Environment
NODE_ENV=production
# Database
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public
# Project Info
PROJECT_NAME="Portabase"
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
PROJECT_URL=http://app.portabase.io
PROJECT_SECRET=
# SMTP (Email)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
# Google OAuth
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
AUTH_GOOGLE_METHOD=
# S3/MinIO Configuration
S3_ENDPOINT=http://app.s3.portabase.io
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_BUCKET_NAME=portabase
S3_PORT=9000
S3_USE_SSL=true
# Storage Backend: 'local' or 's3'
STORAGE_TYPE=local
# Retention
RETENTION_CRON="* * * * *"
```
To get more information about env variables, check
that [link](https://portabase.io/docs/dashboard/advanced/environment)
### Locally (Development)
1. Clone the repository:
```bash
git clone https://github.com/Portabase/portabase
cd portabase
```
2. Start the development database for Portabase service:
```bash
docker compose up
```
3. Start the Next.js app:
```bash
make up
```
---
## 🛠️ Usage
Once the installation process is done, follow the steps to configure your instance.
### Dashboard configuration process
1. **Access the dashboard** Open `http://localhost:8887` in your browser.
2. **Sign up** Register the first user, who will automatically have the **Admin** role in the default workspace.
3. **Add your first agent** Follow [this guide](https://github.com/Portabase/agent-portabase) for setup
instructions.
4. **Create organizations and projects** Link your databases to projects to enable backups and restores.
5. **Configure backup policies** Define schedules (hourly, daily, weekly, or monthly) and retention rules.
6. **Choose a storage provider** Select where backups will be stored (local, S3, etc.).
7. **Save and start** Portabase validates your configuration and starts automated backups based on your defined
policies.
---
## 🤝 Contributing
Contributions are welcome and appreciated! Here's how to get started:
1. Fork the repository
2. Create a new branch:
```bash
git checkout -b features/your-feature
```
3. Commit your changes:
```bash
git commit -m "Add YourFeature"
```
4. Push to the branch:
```bash
git push origin features/your-feature
```
5. Open a pull request
### Top Contributors
## Contributors
[![Contributors](https://contrib.rocks/image?repo=Portabase/portabase)](https://github.com/Portabase/portabase/graphs/contributors)
---
## 📄 License
## License
Distributed under the Apache License. See `LICENSE.txt` for more details.
---
## 🙏 Acknowledgments
Thanks to all contributors and the open-source community!
Give the project a ⭐ if you like it!
[Docker]: https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=fff&style=for-the-badge
@@ -33,16 +33,22 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex items-center">
{capitalizeFirstLetter(agent.name)}
<Link className={buttonVariants({variant: "outline"})}
href={`/dashboard/agents/${agent.id}/edit`}>
<GearIcon className="w-7 h-7"/>
</Link>
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(agent.name)}
</div>
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<Link className={buttonVariants({variant: "outline"})}
href={`/dashboard/agents/${agent.id}/edit`}>
<GearIcon className="w-7 h-7"/>
</Link>
</div>
<div className="flex items-center gap-2">
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
</div>
</div>
</PageTitle>
<PageActions className="justify-between">
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
</PageActions>
</div>
{agent.description && (
@@ -7,7 +7,7 @@ import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/
import {notFound} from "next/navigation";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq, not} from "drizzle-orm";
import {desc, eq, not} from "drizzle-orm";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
import {Metadata} from "next";
@@ -21,11 +21,10 @@ export default async function RoutePage(props: PageParams<{}>) {
where: not(eq(drizzleDb.schemas.agent.isArchived, true)),
with: {
databases: true
}
},
orderBy: (fields) => desc(fields.lastContact),
});
console.log(agents);
if (!agents) {
notFound();
@@ -60,7 +60,7 @@ export default async function RoutePage(props: PageParams<{
orderBy: (r, {desc}) => [desc(r.createdAt)],
});
const isAlreadyBackup = backups.some((b) => b.status === "waiting");
const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const totalBackups = await db.select({count: drizzleDb.schemas.backup.id})
@@ -15,6 +15,11 @@ import {eq} from "drizzle-orm";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import * as drizzleDb from "@/db";
import {capitalizeFirstLetter} from "@/utils/text";
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
import {AlertPolicyModal} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-modal";
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
export default async function RoutePage(props: PageParams<{
projectId: string
@@ -55,22 +60,25 @@ export default async function RoutePage(props: PageParams<{
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex items-center">
{capitalizeFirstLetter(proj.name)}
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(proj.name)}
</div>
{!isMember && (
<Link className={buttonVariants({variant: "outline"})}
href={`/dashboard/projects/${proj.id}/edit`}>
<GearIcon className="w-7 h-7"/>
</Link>
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<Link className={buttonVariants({variant: "outline"})}
href={`/dashboard/projects/${proj.id}/edit`}>
<GearIcon className="w-7 h-7"/>
</Link>
</div>
<div className="flex items-center gap-2">
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
</div>
</div>
)}
</PageTitle>
{!isMember && (
<PageActions className="justify-between">
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
</PageActions>
)}
</div>
<PageDescription>The list of associated databases</PageDescription>
<PageContent className="flex flex-col w-full h-full">
{proj.databases.length > 0 ? (
<CardsWithPagination
@@ -78,8 +86,8 @@ export default async function RoutePage(props: PageParams<{
organizationSlug={organization.slug}
// @ts-ignore
cardItem={ProjectDatabaseCard}
cardsPerPage={4}
numberOfColumns={1}
cardsPerPage={6}
numberOfColumns={3}
extendedProps={proj}
/>
) : (
@@ -56,8 +56,8 @@ export default async function RoutePage(props: PageParams<{}>) {
organizationSlug={organization.slug}
data={projects}
cardItem={ProjectCard}
cardsPerPage={4}
numberOfColumns={1}
cardsPerPage={6}
numberOfColumns={3}
/>
) : isMember ? (
<EmptyStatePlaceholder text="No project available"/>
@@ -11,6 +11,13 @@ import {Metadata} from "next";
import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
import {capitalizeFirstLetter} from "@/utils/text";
import Link from "next/link";
import {buttonVariants} from "@/components/ui/button";
import {GearIcon} from "@radix-ui/react-icons";
import {
ButtonDeleteProject
} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
export const metadata: Metadata = {
title: "Settings",
@@ -26,25 +33,29 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
}
const notificationChannels = await getOrganizationChannels(organization.id)
const permissions = computeOrganizationPermissions(activeMember);
return (
<Page>
<PageHeader>
<PageTitle className="flex items-center">
Organization settings
{permissions.canManageSettings && organization.slug !== "default" && (
<EditButtonSettings/>
)}
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
Organization settings
</div>
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
{permissions.canManageSettings && organization.slug !== "default" && (
<EditButtonSettings/>
)}
</div>
<div className="flex items-center gap-2">
{permissions.canManageDangerZone && organization.slug !== "default" && (
<DeleteOrganizationButton organizationSlug={organization.slug}/>
)}
</div>
</div>
</PageTitle>
<PageActions>
{permissions.canManageDangerZone && organization.slug !== "default" && (
<DeleteOrganizationButton organizationSlug={organization.slug}/>
)}
</PageActions>
</PageHeader>
<PageContent>
<OrganizationTabs
+3 -2
View File
@@ -106,7 +106,7 @@ export async function POST(
if (status === "success") {
const file = formData.get("file") as File | null;
const extension = formData.get("extension") as string | null;
if (!aesKeyHex || !ivHex) {
return NextResponse.json({error: "Missing fields"}, {status: 400});
}
@@ -119,7 +119,8 @@ export async function POST(
);
}
const fileSizeBytes = file.size;
const fileExtension = getFileExtension(database.dbms)
// const fileExtension = '.' + (file.name.split('.').pop()?.toLowerCase() || '');
const fileExtension = extension ? extension : getFileExtension(database.dbms)
const decryptedFile = await decryptedDump(file, aesKeyHex, ivHex, fileExtension);
const uuid = uuidv4();
const fileName = `${uuid}${fileExtension}`;
-1
View File
@@ -24,7 +24,6 @@ export async function POST(
const agentId = (await params).agentId
const body: BodyResultRestore = await request.json();
console.log(body)
if (!isUuidv4(body.generatedId)) {
return NextResponse.json(
+12 -5
View File
@@ -6,7 +6,7 @@ import {Agent} from "@/db/schema/08_agent";
import {Database} from "@/db/schema/07_database";
import * as drizzleDb from "@/db";
import {db as dbClient} from "@/db";
import {and, eq} from "drizzle-orm";
import {and, eq, inArray} from "drizzle-orm";
import {EDbmsSchema} from "@/db/schema/types";
import {ServerActionResult} from "@/types/action-type";
import {SafeActionResult} from "next-safe-action";
@@ -75,8 +75,15 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
.returning();
const backup = await dbClient.query.backup.findFirst({
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.status, "waiting"))
// const backup = await dbClient.query.backup.findFirst({
// where: and(eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.status, "waiting"))
// })
const activeBackup = await dbClient.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"])
)
})
@@ -85,13 +92,13 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
})
if (backup) {
if (activeBackup && activeBackup.status == "waiting") {
backupAction = true
await dbClient
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({status: "ongoing"}))
.where(eq(drizzleDb.schemas.backup.id, backup.id));
.where(eq(drizzleDb.schemas.backup.id, activeBackup.id));
}
if (restoration) {
-3
View File
@@ -41,7 +41,6 @@ export async function POST(
if (!isUuidv4(agentId)) {
message = "agentId is not a valid uuid"
console.log(message)
return NextResponse.json(
{error: "agentId is not a valid uuid"},
{status: 500}
@@ -54,7 +53,6 @@ export async function POST(
if (!agent) {
message = "Agent not found"
console.log(message)
return NextResponse.json({error: message}, {status: 404})
}
const databasesResponse = await handleDatabases(body, agent, lastContact)
@@ -77,7 +75,6 @@ export async function POST(
},
databases: databasesResponse
}
console.log(response)
return Response.json(response)
} catch (error) {
+1 -13
View File
@@ -19,7 +19,6 @@ export async function GET(request: Request) {
new ReadableStream({
start(controller) {
console.log('Stream started');
const handleModification = (data: any) => {
console.log('Modification event triggered:', data);
controller.enqueue(`event: modification\n`);
@@ -45,15 +44,4 @@ export async function GET(request: Request) {
},
}
);
}
// export async function POST(request: Request) {
// console.log('POST request received');
// const data = await request.json();
// console.log('Data received:', data);
//
// // Emit the event to all connected clients
// eventEmitter.emit('modification', data);
//
// return new Response('Event sent', {status: 200});
// }
}
+26 -34
View File
@@ -1,45 +1,37 @@
FROM node:22-alpine AS base
ENV YARN_VERSION=4.9.1
RUN apk add --update --no-cache \
tzdata \
build-base \
libc6-compat \
openssl \
make \
tzdata
FROM base AS build-env
RUN apk add --update --no-cache \
build-base \
g++ \
jpeg-dev \
cairo-dev \
giflib-dev \
pango-dev \
make \
libtool \
autoconf \
automake \
libpng
automake
RUN corepack enable && corepack prepare yarn@${YARN_VERSION}
RUN yarn set version ${YARN_VERSION}
RUN corepack enable && corepack prepare pnpm@latest --activate
FROM base AS deps
FROM build-env AS deps
WORKDIR /app
COPY .yarn ./.yarn
COPY .yarnrc.yml ./
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm i --frozen-lockfile
RUN \
if [ -f yarn.lock ]; then yarn; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
FROM base AS dev
FROM build-env AS dev
WORKDIR /app
@@ -52,8 +44,11 @@ RUN chmod +x /app/docker/entrypoints/app-dev-entrypoint.sh
ENTRYPOINT ["sh","/app/docker/entrypoints/app-dev-entrypoint.sh"]
# Rebuild the source code only when needed
FROM base AS builder
FROM build-env AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
@@ -61,12 +56,11 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
RUN pnpm run build
FROM base AS prod
@@ -109,5 +103,3 @@ ENV HOSTNAME="0.0.0.0"
USER nextjs
ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"]
+3 -3
View File
@@ -3,10 +3,10 @@
set -euo pipefail
echo "▶ Running Drizzle codegen..."
npx drizzle-kit generate
pnpm drizzle-kit generate
echo "▶ Applying migrations..."
npx drizzle-kit migrate
pnpm drizzle-kit migrate
echo "▶ Starting Next.js dev server..."
exec npm run dev
exec pnpm dev
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "portabase",
"version": "1.1.8",
"version": "1.1.12-rc.4",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
@@ -97,6 +97,7 @@
"devDependencies": {
"@iconify/react": "^6.0.0",
"@react-email/preview-server": "4.3.2",
"@react-email/render": "^2.0.1",
"@tailwindcss/postcss": "^4.1.7",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.15.18",
@@ -111,6 +112,7 @@
"eslint": "^9.39.0",
"eslint-config-next": "^16.0.1",
"eslint-plugin-tailwindcss": "^3.18.0",
"framer-motion": "^12.24.7",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.7",
"tsx": "^4.19.4",
@@ -118,5 +120,5 @@
"typescript": "^5.8.3",
"zenstack": "2.14.2"
},
"packageManager": "yarn@4.9.1"
"packageManager": "pnpm@10.27.0"
}
+14158
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- zenstack
Executable
+95
View File
@@ -0,0 +1,95 @@
#!/bin/bash
set -e
if [ -z "$1" ]; then
echo "Usage: ./release <version>"
echo "Example: ./release v1.0.0"
exit 1
fi
VERSION=$1
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$CURRENT_BRANCH" = "main" ]; then
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: On 'main' branch, only release tags (X.Y.Z) are allowed."
exit 1
fi
else
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
echo "Error: On branch '$CURRENT_BRANCH', only RC tags matching X.Y.Z-rc.W are allowed (e.g., 1.0.0-rc.1)."
exit 1
fi
fi
CLEAN_VERSION=${VERSION#v}
CURRENT_DATE=$(date +%Y-%m-%d)
echo "Preparing release $VERSION..."
# package.json
if [ -f package.json ]; then
echo "Updating package.json..."
if sed --version >/dev/null 2>&1; then
sed -i "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json
else
sed -i '' "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json
fi
fi
# pyproject.toml
if [ -f pyproject.toml ]; then
echo "Updating pyproject.toml..."
if sed --version >/dev/null 2>&1; then
sed -i "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" pyproject.toml
else
sed -i '' "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" pyproject.toml
fi
fi
# Cargo.toml
if [ -f Cargo.toml ]; then
echo "Updating Cargo.toml..."
if sed --version >/dev/null 2>&1; then
sed -i "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" Cargo.toml
else
sed -i '' "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" Cargo.toml
fi
fi
# CITATION.cff
if [ -f CITATION.cff ]; then
echo "Updating CITATION.cff..."
if sed --version >/dev/null 2>&1; then
sed -i "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff
sed -i "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff
else
sed -i '' "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff
sed -i '' "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff
fi
fi
git add .
if ! git diff-index --quiet HEAD --; then
echo "Committing changes..."
git commit -m "chore(release): $VERSION"
else
echo "No changes to commit. Proceeding to tag..."
fi
if git rev-parse "$VERSION" >/dev/null 2>&1; then
echo "Tag $VERSION already exists. Aborting."
exit 1
fi
echo "Creating tag $VERSION..."
git tag -a "$VERSION" -m "Release $VERSION"
echo "Pushing changes and tags to remote..."
git push
git push origin "$VERSION"
echo "Successfully released $VERSION!"
@@ -63,7 +63,6 @@ export const LoginForm = (props: loginFormProps) => {
toast.success("Login success");
},
onError: (error) => {
console.log(error);
toast.error(error.error.message);
},
}
@@ -28,7 +28,6 @@ export const resetPasswordAction = action
}
const user = await (await auth.$context).internalAdapter.findUserById(verification.value);
console.log(user)
if (!user) {
return {
success: false,
@@ -39,28 +38,6 @@ export const resetPasswordAction = action
};
}
const hashedPassword = await (await auth.$context).password.hash(parsedInput.schema.password);
console.log(hashedPassword)
console.log("ok")
// await (await auth.$context).internalAdapter.updatePassword(user.id, hashedPassword);
console.log("ici")
// await (await auth.$context).internalAdapter.deleteSessions(user.id);
// console.log("ici2")
// await (await auth.$context).internalAdapter.deleteVerificationValue(verification.id);
// console.log("ici3");
//
// (await auth.$context).internalAdapter.updateUser(user.id, {
// lastChangedPasswordAt: new Date(),
// });
// await auth.api.resetPassword({
// headers: await headers(),
// body: {
// newPassword: parsedInput.schema.password,
// token: parsedInput.token,
// },
// });
await (
await auth.$context
).internalAdapter.updateUser(user.id, {
@@ -44,7 +44,6 @@ export const ResetPasswordForm = (props: ResetPasswordFormProps) => {
setTimeout(() => router.push("/"), 1400);
},
onError: (error: BetterAuthError) => {
console.log(error)
toast.error("An error occurred while resetting password");
},
});
@@ -36,7 +36,6 @@ export const RegisterForm = (props: registerFormProps) => {
router.push(`/login`);
},
onError: (error) => {
console.log(error);
toast.error(error.error.message);
},
});
@@ -6,7 +6,7 @@ import {Form, FormControl, FormField, FormItem, FormLabel, useZodForm} from "@/c
import {PasswordStrengthInput} from "@/components/ui/password-input-indicator";
import {ResetPasswordSchema, ResetPasswordType} from "@/components/wrappers/auth/reset-password/reset-password-schema";
import {PasswordInput} from "@/components/ui/password-input";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {CardContent, CardHeader} from "@/components/ui/card";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {authClient} from "@/lib/auth/auth-client";
import {toast} from "sonner";
@@ -31,7 +31,6 @@ export const ResetPasswordForm = ({token}: ResetPasswordFormProps) => {
token,
}, {
onSuccess: (response) => {
console.log(response);
toast.success("Password changed successfully.");
setTimeout(() => router.push("/"), 1000);
},
@@ -12,8 +12,6 @@ export const ConnectionCircle = ({date}: ConnectionCircleProps) => {
const timestamp = date.getTime();
const interval_seconds = (now - timestamp) / 1000;
console.log({now, timestamp, interval_seconds});
if (interval_seconds < 55) {
style = "bg-green-400 border-green-600";
} else if (interval_seconds <= 60) {
@@ -25,7 +23,5 @@ export const ConnectionCircle = ({date}: ConnectionCircleProps) => {
console.warn("Invalid date passed to ConnectionCircle:", date);
}
console.log(style);
return <div className={cn("w-5 h-5 rounded-full border-4", style)}/>;
};
@@ -29,10 +29,6 @@ export const NotifierChannelOrganisationForm = ({
const router = useRouter();
console.log("defaultValues", defaultValues);
console.log("organizations", organizations);
const defaultOrganizationIds = defaultValues?.organizations?.map(organization => organization.organizationId) ?? []
@@ -1,4 +1,5 @@
import {Mail, MessageSquare, Webhook} from "lucide-react";
import {Mail} from "lucide-react";
import type { SVGProps } from 'react';
export const getNotificationChannelIcon = (type: string) => {
const Icon = notificationTypes.find((t) => t.value === type)?.icon
@@ -6,8 +7,481 @@ export const getNotificationChannelIcon = (type: string) => {
}
const notificationTypes = [
export const notificationTypes = [
{value: "smtp", label: "Email", icon: Mail},
{value: "slack", label: "Slack", icon: MessageSquare},
{value: "webhook", label: "Webhook", icon: Webhook},
]
{value: "slack", label: "Slack", icon: SlackIcon},
{value: "discord", label: "Discord", icon: DiscordIcon},
{value: "telegram", label: "Telegram", icon: TelegramIcon},
{value: "gotify", label: "Gotify", icon: GotifyIcon},
{value: "ntfy", label: "ntfy.sh", icon: NtfyIcon},
{value: "webhook", label: "Webhook", icon: WebhookIcon},
]
export function SlackIcon(props: SVGProps<SVGSVGElement>) {
return (<svg xmlns="http://www.w3.org/2000/svg" width={256} height={256} viewBox="0 0 256 256" {...props}><path fill="#e01e5a" d="M53.841 161.32c0 14.832-11.987 26.82-26.819 26.82S.203 176.152.203 161.32c0-14.831 11.987-26.818 26.82-26.818H53.84zm13.41 0c0-14.831 11.987-26.818 26.819-26.818s26.819 11.987 26.819 26.819v67.047c0 14.832-11.987 26.82-26.82 26.82c-14.83 0-26.818-11.988-26.818-26.82z"></path><path fill="#36c5f0" d="M94.07 53.638c-14.832 0-26.82-11.987-26.82-26.819S79.239 0 94.07 0s26.819 11.987 26.819 26.819v26.82zm0 13.613c14.832 0 26.819 11.987 26.819 26.819s-11.987 26.819-26.82 26.819H26.82C11.987 120.889 0 108.902 0 94.069c0-14.83 11.987-26.818 26.819-26.818z"></path><path fill="#2eb67d" d="M201.55 94.07c0-14.832 11.987-26.82 26.818-26.82s26.82 11.988 26.82 26.82s-11.988 26.819-26.82 26.819H201.55zm-13.41 0c0 14.832-11.988 26.819-26.82 26.819c-14.831 0-26.818-11.987-26.818-26.82V26.82C134.502 11.987 146.489 0 161.32 0s26.819 11.987 26.819 26.819z"></path><path fill="#ecb22e" d="M161.32 201.55c14.832 0 26.82 11.987 26.82 26.818s-11.988 26.82-26.82 26.82c-14.831 0-26.818-11.988-26.818-26.82V201.55zm0-13.41c-14.831 0-26.818-11.988-26.818-26.82c0-14.831 11.987-26.818 26.819-26.818h67.25c14.832 0 26.82 11.987 26.82 26.819s-11.988 26.819-26.82 26.819z"></path></svg>);
}
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
return (<svg xmlns="http://www.w3.org/2000/svg" width={256} height={199} viewBox="0 0 256 199" {...props}><path fill="#5865f2" d="M216.856 16.597A208.5 208.5 0 0 0 164.042 0c-2.275 4.113-4.933 9.645-6.766 14.046q-29.538-4.442-58.533 0c-1.832-4.4-4.55-9.933-6.846-14.046a207.8 207.8 0 0 0-52.855 16.638C5.618 67.147-3.443 116.4 1.087 164.956c22.169 16.555 43.653 26.612 64.775 33.193A161 161 0 0 0 79.735 175.3a136.4 136.4 0 0 1-21.846-10.632a109 109 0 0 0 5.356-4.237c42.122 19.702 87.89 19.702 129.51 0a132 132 0 0 0 5.355 4.237a136 136 0 0 1-21.886 10.653c4.006 8.02 8.638 15.67 13.873 22.848c21.142-6.58 42.646-16.637 64.815-33.213c5.316-56.288-9.08-105.09-38.056-148.36M85.474 135.095c-12.645 0-23.015-11.805-23.015-26.18s10.149-26.2 23.015-26.2s23.236 11.804 23.015 26.2c.02 14.375-10.148 26.18-23.015 26.18m85.051 0c-12.645 0-23.014-11.805-23.014-26.18s10.148-26.2 23.014-26.2c12.867 0 23.236 11.804 23.015 26.2c0 14.375-10.148 26.18-23.015 26.18"></path></svg>);
}
export function TelegramIcon(props: SVGProps<SVGSVGElement>) {
return (<svg xmlns="http://www.w3.org/2000/svg" width={256} height={256} viewBox="0 0 256 256" {...props}><defs><linearGradient id="SVGuySfwdaH" x1="50%" x2="50%" y1="0%" y2="100%"><stop offset="0%" stopColor="#2aabee"></stop><stop offset="100%" stopColor="#229ed9"></stop></linearGradient></defs><path fill="url(#SVGuySfwdaH)" d="M128 0C94.06 0 61.48 13.494 37.5 37.49A128.04 128.04 0 0 0 0 128c0 33.934 13.5 66.514 37.5 90.51C61.48 242.506 94.06 256 128 256s66.52-13.494 90.5-37.49c24-23.996 37.5-56.576 37.5-90.51s-13.5-66.514-37.5-90.51C194.52 13.494 161.94 0 128 0"></path><path fill="#fff" d="M57.94 126.648q55.98-24.384 74.64-32.152c35.56-14.786 42.94-17.354 47.76-17.441c1.06-.017 3.42.245 4.96 1.49c1.28 1.05 1.64 2.47 1.82 3.467c.16.996.38 3.266.2 5.038c-1.92 20.24-10.26 69.356-14.5 92.026c-1.78 9.592-5.32 12.808-8.74 13.122c-7.44.684-13.08-4.912-20.28-9.63c-11.26-7.386-17.62-11.982-28.56-19.188c-12.64-8.328-4.44-12.906 2.76-20.386c1.88-1.958 34.64-31.748 35.26-34.45c.08-.338.16-1.598-.6-2.262c-.74-.666-1.84-.438-2.64-.258c-1.14.256-19.12 12.152-54 35.686c-5.1 3.508-9.72 5.218-13.88 5.128c-4.56-.098-13.36-2.584-19.9-4.708c-8-2.606-14.38-3.984-13.82-8.41c.28-2.304 3.46-4.662 9.52-7.072"></path></svg>);
}
export function NtfyIcon(props: SVGProps<SVGSVGElement>) {
return ( <svg
xmlns="http://www.w3.org/2000/svg"
xmlSpace="preserve"
id="Layer_1"
x={0}
y={0}
viewBox="0 0 512 512"
{...props}
>
<style>{".st1{fill:#fff}"}</style>
<linearGradient
id="SVGID_1_"
x1={115.045}
x2={142.93}
y1={512.747}
y2={491.741}
gradientTransform="matrix(15.8584 0 0 -15.8584 -1816.246 8200.18)"
gradientUnits="userSpaceOnUse"
>
<stop
offset={0}
style={{
stopColor: "#348878",
}}
/>
<stop
offset={1}
style={{
stopColor: "#56bda8",
}}
/>
</linearGradient>
<path
d="M441.8 64.8H84.7c-24.5 0-44.6 19-44.6 42.1l.3 285.3-6.7 38.4 95.6-28.8h312.4c24.5 0 44.6-19 44.6-42.1V106.9c.1-23.2-20-42.1-44.5-42.1"
style={{
fill: "url(#SVGID_1_)",
}}
/>
<path
d="M78.1 32.7C40.9 32.7 9 62 9 99.2l.4 311.2-9.4 69 127.1-33.8H443c37.2 0 69.1-29.3 69.1-66.5V99.2c0-37.2-31.9-66.5-69-66.5zm0 46.9H443c13.1 0 22.1 9.5 22.1 19.6V379c0 10-9 19.8-22.1 19.6H120.2l-64.6 19.5.7-3.8-.4-315.1c0-10.1 9.1-19.6 22.2-19.6"
className="st1"
/>
<path
d="M116.4 310.9v-34.8l73.1-38.7c2.4-1.2 4.6-2.1 6.8-2.7 2.3-.8 4.5-1.4 6.3-1.8l6-.6V230q-3-.3-6-1.2-2.85-.6-6.3-1.5c-2.2-.8-4.5-1.8-6.8-3l-73.1-38.7v-35.1l113 61.5v37.4z"
className="st1"
/>
<path
d="M110.5 139.7v50.4l76.6 40.6.5.2-.5.2-76.6 40.6v50.1l8.2-4.5L235.1 254v-46.4zm11.8 21.7 101.2 55.1V245l-101.2 55.1v-19.4l69.6-36.8.1-.1c1.9-1 3.8-1.7 5.8-2.3h.1l.2-.1c2.1-.7 3.9-1.2 5.5-1.6l11.1-1.1v-15.2l-5.4-.5c-1.7-.2-3.5-.5-5.1-1.1h-.2l-.2-.1c-2-.4-3.9-.9-5.9-1.4-2-.7-4-1.6-5.9-2.6l-.1-.1-69.6-36.7zm152.3 137.4h120.7v32.6H274.6z"
className="st1"
/>
<path
d="M268.7 292.1v46h132.4v-46zm11.7 13.4h109v19.2h-109z"
className="st1"
/>
</svg>)
}
export function GotifyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlSpace="preserve"
id="Layer_2"
x={0}
y={0}
viewBox="46.6 113.46 377.2 307.81"
{...props}
>
<style>
{
".st0{fill:#ddcba2}.st0,.st1,.st2,.st4,.st5{stroke:#000;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10}.st1{fill:#71caee}.st2{fill:#fff}.st4{fill:#f0f0f0}.st5{fill:none}"
}
</style>
<switch>
<foreignObject
width={1}
height={1}
x={0}
y={0}
requiredExtensions="http://ns.adobe.com/AdobeIllustrator/10.0/"
/>
<g>
<path
d="M317.7 376.2c6.2-1.7 15.8 0 19.5 3.2 5.5 4.8 4.9 20.9 1.1 29-.8 1.7-1.8 3.4-3.2 4.4s-3.4 1.2-4.7 0-1.7-3.3-1.9-5.2c-.2-3.1-.2-6.2.3-9.2.2-1.3.2-2.9-.2-4.2-.6-2.2-2.5-3.1-4.5-3.5-3.4-.8-14.3-.7-19.5-.7-.8 0 1.5-9.1 2-9.7 2.6-2.9 7.5-3.1 11.1-4.1z"
className="st0"
/>
<path
d="m258.9 119.7-9-2.7c-4.6-1.4-9.2-2.8-14-2.5-2.8.2-6.1 1.3-6.9 4-.6 2-1.6 7.3-1.3 7.9 1.5 3.4 13.9 6.7 18.3 6.7"
className="st1"
/>
<path d="M392.6 177.9c-1.4 1.4-2.2 3.5-2.5 5.5-.2 1.4-.1 3 .5 4.3s1.8 2.3 3.1 3c1.3.6 2.8.9 4.3.9 1.1 0 2.3-.1 3.1-.9.6-.7.8-1.6.9-2.5.2-2.3-.1-4.7-.9-6.9-.4-1.1-.9-2.3-1.8-3.1-1.7-1.8-4.5-2.2-6.4-.5-.1 0-.2.1-.3.2" />
<path
d="M358.5 164.2c-1-1 0-2.7 1-3.7 5.8-5.2 15.1-4.6 21.8-.6 10.9 6.6 15.6 19.9 17.2 32.5.6 5.2.9 10.6-.5 15.7s-4.6 9.9-9.3 12.1c-1.1.5-2.3.9-3.4.5s-1.9-1.8-1.2-2.8c-9.4-13.6-19-26.8-20.9-43.2-.5-4.1-1.8-7.4-4.7-10.5z"
className="st2"
/>
<path
d="M134.7 328.4c-5.1-3.1-9.9-6.6-14.3-10.6-1.3-1.2-2.6-2.5-2.6-4.3 0-1.2.6-2.2 1.2-3.2.8-1.4 1.7-2.8 2.5-4.1 1.1-1.8 2.9-3.9 4.9-3.2.9.3 1.5 1.1 2 1.8 2.4 3.3 4.9 6.6 7.3 9.8 1.5 2 3.7 4.3 6.1 3.5"
className="st1"
/>
<path
d="M209.6 133c33.2-18 77.8-19.6 111.5-8.7 24.3 7.9 43.4 26.7 53.3 50 8.7 20.6 10.5 43.6 8.1 65.7-4.4 40.2-20.2 77.9-40.3 112.6-11.1 19-21.8 36-40.5 48.5-36.8 24.6-87.2 22.1-128.4 11.5-19.9-5.1-39.7-17.3-47.2-37.3-4.8-12.8-4.2-27.6 1.5-40 11.6-24.8 43.2-38.4 45.6-67.9.7-8.7-1.6-17.3-3.6-25.7-5.6-23.4-8.9-45.8 1.4-68.7 8.1-17.7 21.9-31 38.6-40z"
className="st1"
/>
<path
d="M189.8 151.4c-5.4-5.2-11.9-8.8-19-10.3-2.2-.5-4.7-.7-6.9.7-1.8 1.2-3.1 3.3-4.2 5.3-1.6 3-3 6.2-4.1 9.4-.4 1.2-.6 2.5 0 3.5.3.6.9.9 1.5 1.2 8.1 4.2 16.8 7.1 25.5 9.8"
className="st1"
/>
<path
d="M183.7 158.7c-2.5-1.8-16.8-12.1-18.7-4.8-.4 1.6.5 3.9 1.5 4.8"
className="st1"
/>
<path
d="M264.5 174.9c-.5.5-.9 1-1.3 1.6-9 11.6-12 27.9-9.3 42.1 1.7 9 5.9 17.9 13.2 23.4 19.3 14.6 51.5 13.5 68.4-1.5 24.4-21.7 13-67.6-14-78.8-17.6-7.2-43.7-1.6-57 13.2zm117.6 62.2c1.4-.1 2.9-.1 4.3.1.3 0 .7.1 1 .4.2.3.4.7.5 1.1 1 3.9.5 8.2.1 12.4-.1.9-.2 1.8-.6 2.6-1 2.1-3.1 2.7-4.7 2.7-.1 0-.2 0-.3-.1-.3-.2-.3-.7-.2-1.2.3-5.9-.1-11.9-.1-18q0 .15 0 0z"
className="st2"
/>
<path
d="M378.7 236.8c-1.4.4-2.5 2-2.8 4.4-.5 4.4-.7 8.9-.5 13.4 0 .9.1 1.9.5 2.4.2.3.5.4.8.4 1.6.3 4.1-.6 5.6-1 0 0 0-5.2-.1-8s-.1-6.1-.2-8.9v-2.2c.1-.7-2.6-.7-3.3-.5z"
className="st2"
/>
<path
d="M358.3 231.8c-.3 2.2.1 4.7 1.7 7.4 2.6 4.4 7 6.1 11.9 5.8 8.9-.6 25.3-5.4 27.5-15.7.6-3-.3-6.1-2.2-8.5-6.2-7.8-17.8-5.7-25.6-2-5.9 2.7-12.4 7-13.3 13z"
className="st0"
/>
<path
d="M386.4 208.6c2.2 1.4 3.7 3.8 4 7 .3 3.6-1.4 7.5-5 8.8-2.9 1.1-6.2.6-9.1-.4s-5.8-2.8-6.8-5.7c-.7-2-.3-4.3.7-6.1 1.1-1.8 2.8-3.2 4.7-4.1 3.9-1.8 8.4-1.6 11.5.5z"
style={{
fill: "#888e93",
stroke: "#000",
strokeWidth: 2,
strokeLinecap: "round",
strokeMiterlimit: 10,
}}
/>
<path
d="M414.7 262.6c2.4.6 4.8 2.1 5.6 4.4s.1 4.9-1.6 6.7-4.2 2.5-6.6 2.5c-.8 0-1.7-.1-2.4-.5-2.5-1.1-3.5-4-4.2-6.6-1.8-6.8 3.6-7.8 9.2-6.5z"
className="st0"
/>
<path
d="M267.1 284.7c2.3-4.5 141.3-36.2 144.7-31.6 3.4 4.5 15.8 88.2 9 90.4-6.8 2.3-119.8 37.3-126.6 35s-29.4-89.3-27.1-93.8z"
className="st4"
/>
<path
d="M294.2 378.5s54.3-74.6 59.9-76.9c5.7-2.3 67.3 41.3 67.3 41.3"
className="st5"
/>
<path
d="M267 287.7s86 38.8 91.6 36.6c5.7-2.3 53.1-71.2 53.1-71.2"
className="st4"
/>
<linearGradient
id="SVGID_1_"
x1={131.119}
x2={103.259}
y1={373.573}
y2={396.951}
gradientUnits="userSpaceOnUse"
>
<stop
offset={0}
style={{
stopColor: "#71caee",
}}
/>
<stop
offset={0.041}
style={{
stopColor: "#83cae2",
}}
/>
<stop
offset={0.121}
style={{
stopColor: "#9fcace",
}}
/>
<stop
offset={0.211}
style={{
stopColor: "#b6cbbe",
}}
/>
<stop
offset={0.314}
style={{
stopColor: "#c7cbb1",
}}
/>
<stop
offset={0.438}
style={{
stopColor: "#d4cba8",
}}
/>
<stop
offset={0.606}
style={{
stopColor: "#dbcba3",
}}
/>
<stop
offset={1}
style={{
stopColor: "#ddcba2",
}}
/>
</linearGradient>
<path
d="M132.8 375.6c-3.5 3.8-7.3 7.8-13 9.2-4.6 1.2-10 .2-13.6-2.3-1.4-1-2.6-2.2-4-3.2-1.5-1-3.4-1.7-5.3-1.3-2.7.5-4.1 3.1-3.6 5.3 2 8.8 17 15.6 27.5 15.5 9 0 19-4.6 21.4-11.8"
style={{
fill: "url(#SVGID_1_)",
}}
/>
<path
d="M132.8 375.6c-3.5 3.8-7.3 7.8-13 9.2-4.6 1.2-10 .2-13.6-2.3-1.4-1-2.6-2.2-4-3.2-1.5-1-3.4-1.7-5.3-1.3-2.7.5-4.1 3.1-3.6 5.3 2 8.8 17 15.6 27.5 15.5 9 0 19-4.6 21.4-11.8"
className="st5"
/>
<linearGradient
id="SVGID_2_"
x1={265.033}
x2={275.301}
y1={280.295}
y2={302.314}
gradientUnits="userSpaceOnUse"
>
<stop
offset={0}
style={{
stopColor: "#71caee",
}}
/>
<stop
offset={0.041}
style={{
stopColor: "#83cae2",
}}
/>
<stop
offset={0.121}
style={{
stopColor: "#9fcace",
}}
/>
<stop
offset={0.211}
style={{
stopColor: "#b6cbbe",
}}
/>
<stop
offset={0.314}
style={{
stopColor: "#c7cbb1",
}}
/>
<stop
offset={0.438}
style={{
stopColor: "#d4cba8",
}}
/>
<stop
offset={0.606}
style={{
stopColor: "#dbcba3",
}}
/>
<stop
offset={1}
style={{
stopColor: "#ddcba2",
}}
/>
</linearGradient>
<path
d="M261.9 283.5c-.1 4.2 4.3 7.3 8.4 7.6s8.2-1.3 12.2-2.6c1.4-.4 2.9-.8 4.2-.2 1.8.9 2.7 4.1 1.8 5.9s-3.4 3.5-5.3 4.4c-6.5 3-12.9 3.6-19.9 2-5.3-1.2-11.3-4.3-13-13.5"
style={{
fill: "url(#SVGID_2_)",
}}
/>
<path
d="M261.9 283.5c-.1 4.2 4.3 7.3 8.4 7.6s8.2-1.3 12.2-2.6c1.4-.4 2.9-.8 4.2-.2 1.8.9 2.7 4.1 1.8 5.9s-3.4 3.5-5.3 4.4c-6.5 3-12.9 3.6-19.9 2-5.3-1.2-11.3-4.3-13-13.5"
className="st5"
/>
<path d="M318.4 198.4c-2-.3-4.1.1-5.9 1.3-3.2 2.1-4.7 6.2-4.7 9.9 0 1.9.4 3.8 1.4 5.3 1.2 1.7 3.1 2.9 5.2 3.4 3.4.8 8.2.7 10.5-2.5 1-1.5 1.4-3.3 1.5-5.1.5-5.7-1.8-11.4-8-12.3" />
<path
d="M320.4 203.3c.9.3 1.7.8 2.1 1.7.4.8.4 1.7.3 2.5-.1 1-.6 2-1.5 2.7-.7.5-1.7.7-2.6.5s-1.7-.8-2.2-1.6c-1.1-1.6-.9-4.4.9-5.5.9-.4 2-.6 3-.3"
style={{
fill: "#fff",
}}
/>
<linearGradient
id="SVGID_3_"
x1={-18.456}
x2={110.382}
y1={252.286}
y2={252.286}
gradientTransform="rotate(-5.297 -26.409 -856.73)scale(.99997)"
gradientUnits="userSpaceOnUse"
>
<stop
offset={0}
style={{
stopColor: "#333",
stopOpacity: 0,
}}
/>
<stop
offset={0.477}
style={{
stopColor: "#575756",
stopOpacity: 0.2,
}}
/>
<stop
offset={0.995}
style={{
stopColor: "#333",
stopOpacity: 0.001911551,
}}
/>
<stop
offset={1}
style={{
stopColor: "#333",
stopOpacity: 0,
}}
/>
</linearGradient>
<path
d="M81.6 222.4s56.5 68.4 123.9 42.4l6.6-29.7C150 272.2 82.8 214.3 82.8 214.3z"
style={{
fill: "url(#SVGID_3_)",
}}
/>
<linearGradient
id="SVGID_4_"
x1={110.198}
x2={283.737}
y1={262.493}
y2={262.493}
gradientTransform="matrix(.9991 .04164 -.04164 .9991 -36.323 1.051)"
gradientUnits="userSpaceOnUse"
>
<stop
offset={0}
style={{
stopColor: "#333",
stopOpacity: 0,
}}
/>
<stop
offset={0.477}
style={{
stopColor: "#575756",
stopOpacity: 0.2,
}}
/>
<stop
offset={0.995}
style={{
stopColor: "#333",
stopOpacity: 0.001911551,
}}
/>
<stop
offset={1}
style={{
stopColor: "#333",
stopOpacity: 0,
}}
/>
</linearGradient>
<path
d="M64.2 234.9s63.1 101.5 157.8 78.9l14.2-38.5c-89.5 38.3-168.9-51-168.9-51z"
style={{
fill: "url(#SVGID_4_)",
}}
/>
<linearGradient
id="SVGID_5_"
x1={218.967}
x2={428.745}
y1={263.594}
y2={263.594}
gradientTransform="rotate(9.265 -38.832 -826.002)"
gradientUnits="userSpaceOnUse"
>
<stop
offset={0}
style={{
stopColor: "#333",
stopOpacity: 0,
}}
/>
<stop
offset={0.477}
style={{
stopColor: "#575756",
stopOpacity: 0.2,
}}
/>
<stop
offset={0.995}
style={{
stopColor: "#333",
stopOpacity: 0.001911551,
}}
/>
<stop
offset={1}
style={{
stopColor: "#333",
stopOpacity: 0,
}}
/>
</linearGradient>
<path
d="M46.6 251.5s61.1 130.9 178 117.5l22.6-44.2c-113 33-195.3-85.7-195.3-85.7z"
style={{
fill: "url(#SVGID_5_)",
}}
/>
</g>
</switch>
</svg>
)
}
export function WebhookIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={2500}
height={2448}
fillRule="evenodd"
strokeLinejoin="round"
strokeMiterlimit={2}
clipRule="evenodd"
viewBox="-6.74 -6.74 223.55 218.799"
{...props}
>
<path
fill="#7547db"
d="M111.134 71.906a21 21 0 0 1-1.324.042c-11.314 0-20.5-9.186-20.5-20.5s9.186-20.5 20.5-20.5 20.5 9.186 20.5 20.5a20.4 20.4 0 0 1-4.582 12.913l22.66 41.655a51 51 0 0 1 17.422-3.068c28.148 0 51 22.852 51 51s-22.852 51-51 51a51.2 51.2 0 0 1-28.036-8.373 7.303 7.303 0 0 1-1.359-11.105c.015-.016.031-.033.061-.036a7.665 7.665 0 0 1 9.736-1.228 36.06 36.06 0 0 0 19.598 5.792c19.897 0 36.05-16.153 36.05-36.05s-16.153-36.05-36.05-36.05c-6.89 0-16.669 3.014-22.548 6.745a2.637 2.637 0 0 1-3.899-.845zm-9.412 88.614a51 51 0 0 1-6.054 16.62c-14.074 24.378-45.29 32.743-69.667 18.669S-6.74 150.518 7.333 126.14a51.2 51.2 0 0 1 21.27-20.094 7.3 7.3 0 0 1 6.167-.211 7.3 7.3 0 0 1 4.13 4.587c.006.022.012.043 0 .07a7.665 7.665 0 0 1-3.804 9.047 36.05 36.05 0 0 0-14.816 14.076c-9.948 17.23-4.036 39.297 13.195 49.246 17.231 9.948 39.298 4.035 49.246-13.196 3.445-5.967 5.724-15.943 5.433-22.9a2.632 2.632 0 0 1 2.681-2.953l59.054 1.499q.292-.59.626-1.168c5.657-9.798 18.205-13.161 28.003-7.504 9.8 5.658 13.161 18.206 7.504 28.004s-18.205 13.16-28.004 7.503a20.4 20.4 0 0 1-8.892-10.424zM76.97 90.555A51 51 0 0 1 65.601 77c-14.074-24.377-5.71-55.594 18.667-69.667S139.862 1.624 153.935 26a51.2 51.2 0 0 1 6.768 28.466 7.306 7.306 0 0 1-8.938 6.73c-.022-.005-.044-.011-.062-.035a7.664 7.664 0 0 1-5.932-7.817 36.04 36.04 0 0 0-4.783-19.87c-9.948-17.23-32.014-23.143-49.245-13.195S68.599 52.294 78.548 69.525c3.445 5.967 10.945 12.929 17.115 16.155a2.635 2.635 0 0 1 1.218 3.799l-30.826 50.393q.366.548.7 1.125c5.656 9.799 2.294 22.347-7.505 28.004s-22.346 2.295-28.003-7.504-2.295-22.346 7.503-28.003a20.4 20.4 0 0 1 13.474-2.488z"
/>
</svg>
)
}
@@ -34,13 +34,11 @@ export const OrganizationAddMemberForm = ({onSuccessAction, users, organization}
const mutationAddMemberOrganisation = useMutation({
mutationFn: async (data: AddMemberSchemaType) => {
console.log(data);
const result = await addMemberOrganizationAction({
userId: data.userId,
organizationId: organization.id,
role: "member",
});
console.log(result);
toast.success("Member successfully added!");
router.refresh();
onSuccessAction?.();
@@ -71,7 +69,6 @@ export const OrganizationAddMemberForm = ({onSuccessAction, users, organization}
placeholder="Enter a user email"
entries={filteredUsers}
onSelect={(entySelected: any) => {
console.log("Form selection:", entySelected);
field.onChange(entySelected.value);
}}
/>
@@ -35,13 +35,11 @@ export const OrganizationDeleteMemberModal = ({ member, open, onOpenChangeAction
},
{
onSuccess: async (response) => {
console.log(response);
toast.success("Member successfully deleted!");
onOpenChangeAction(false);
router.refresh();
},
onError: async (error) => {
console.log(error);
toast.error("An error occurred while deleting member!");
onOpenChangeAction(false);
},
@@ -49,7 +49,6 @@ export const OrganizationMemberChangeRoleModal = (props: OrganizationMemberChang
router.refresh();
},
onError: (error) => {
console.log(error);
toast.error("An error occurred while updating member");
onOpenChangeAction(false);
},
@@ -95,8 +95,6 @@ export const EmailForm = (props: EmailFormProps) => {
return (
<TooltipProvider>
<Card>
<CardContent>
<Form
form={form}
className="flex flex-col gap-4 mt-3"
@@ -182,23 +180,6 @@ export const EmailForm = (props: EmailFormProps) => {
)}
/>
<div className="flex justify-between gap-4">
{/*{props.defaultValues?.smtpFrom && (*/}
{/* <ButtonWithLoading*/}
{/* type="button"*/}
{/* isPending={mutationSendEmailTest.isPending}*/}
{/* onClick={async () => {*/}
{/* await mutationSendEmailTest.mutateAsync();*/}
{/* }}*/}
{/* icon={<Send/>}*/}
{/* size="default"*/}
{/* className="bg-green-600 hover:bg-green-700 text-white font-medium shadow-sm transition-all"*/}
{/* >Send email test</ButtonWithLoading>*/}
{/*)}*/}
{props.defaultValues?.smtpFrom && (
<Tooltip>
<TooltipTrigger asChild>
@@ -227,14 +208,13 @@ export const EmailForm = (props: EmailFormProps) => {
)}
</Tooltip>
)}
<Button
type="submit"
>Save</Button>
<div className="flex justify-end">
<Button type="submit">
Save
</Button>
</div>
</div>
</Form>
</CardContent>
</Card>
</TooltipProvider>
);
};
@@ -28,11 +28,9 @@ export const StorageS3Form = (props: S3FormProps) => {
const mutation = useMutation({
mutationFn: async (values: S3FormType) => {
console.log(values);
const updateS3Settings = await updateS3SettingsAction({ name: "system", data: values });
const data = updateS3Settings?.data?.data;
if (updateS3Settings?.serverError || !data) {
console.log(updateS3Settings?.serverError);
toast.error(updateS3Settings?.serverError);
return;
}
@@ -39,7 +39,6 @@ export const AdminUserEditForm = ({ onSuccess, defaultValues }: AdminUserEditFor
...data,
id: defaultValues?.id || "",
});
console.log(result)
const inner = result?.data;
if (inner?.success) {
toast.success("User Successfully updated");
@@ -1,11 +1,15 @@
"use client";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {useState} from "react";
import Link from "next/link";
import {formatDateLastContact} from "@/utils/date-formatting";
import {Card} from "@/components/ui/card";
import {ConnectionCircle} from "@/components/wrappers/common/connection-circle";
import {Agent, AgentWith} from "@/db/schema/08_agent";
import {Activity, Database, ShieldCheck} from "lucide-react";
import {formatDateLastContact} from "@/utils/date-formatting";
import {AgentWith} from "@/db/schema/08_agent";
import {Activity, ChevronRight, Copy, Check, Fingerprint, Server, Database, ShieldCheck} from "lucide-react";
import {Badge} from "@/components/ui/badge";
import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile";
export type agentCardProps = {
data: AgentWith;
@@ -13,39 +17,79 @@ export type agentCardProps = {
export const AgentCard = (props: agentCardProps) => {
const {data: agent} = props;
const isMobile = useIsMobile();
const [isCopied, setIsCopied] = useState(false);
const handleCopy = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
navigator.clipboard.writeText(agent.id);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
};
return (
<Link href={`/dashboard/agents/${agent.id}`}
className="block transition-all duration-200 hover:scale-[1.01] hover:shadow-md rounded-xl"
<Link
href={`/dashboard/agents/${agent.id}`}
className="group block transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
>
<Card className="flex flex-row justify-between">
<div className="flex-1 text-left">
<CardHeader className="text-2xl font-bold">{agent.name}</CardHeader>
<CardContent>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4"/>
<span>{formatDateLastContact(agent.lastContact)}</span>
</div>
<div className="flex items-center gap-2">
<Database className="h-4 w-4"/>
<span>{agent.databases?.length ?? 0} DB</span>
</div>
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4"/>
{agent.version ?
<span>v{agent.version}</span>
:
<span>N/A</span>
}
</div>
</div>
</CardContent>
<Card className="flex flex-row items-center p-4 gap-5 transition-all border-border/50 bg-card hover:bg-accent/50 hover:border-primary/50 group-hover:shadow-md overflow-hidden">
<div className="relative w-12 h-12 p-2.5 bg-background rounded-xl border border-border/50 shadow-sm flex items-center justify-center group-hover:border-primary/30 transition-all duration-300 group-hover:scale-105 shrink-0">
<Server className="w-6 h-6 text-foreground/80 group-hover:text-primary transition-colors" />
</div>
<div className="flex items-center px-4">
<ConnectionCircle date={agent.lastContact}/>
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
<div className="flex items-center gap-3">
<h3 className="text-lg font-black text-foreground group-hover:text-primary transition-colors truncate tracking-tight ">
{isMobile ? truncateWords(agent.name, 2) : agent.name}
</h3>
{agent.version && (
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-bold tracking-wider">
v{agent.version}
</Badge>
)}
</div>
<div className="flex items-center gap-4 text-muted-foreground group-hover:text-foreground transition-colors">
<div className="hidden sm:flex items-center gap-1.5 min-w-0">
<Fingerprint className="w-3.5 h-3.5 shrink-0" />
<span className="font-mono text-xs font-bold truncate opacity-80">
{agent.id}
</span>
<button
onClick={handleCopy}
className="ml-0.5 p-1 hover:bg-muted rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 z-10"
title="Copy ID"
>
{isCopied ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
</button>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Database className="w-3.5 h-3.5" />
<span className="text-xs font-bold uppercase tracking-tight opacity-80">
{agent.databases?.length ?? 0} DBs
</span>
</div>
</div>
</div>
<div className="flex items-center gap-4 shrink-0">
<div className="hidden sm:flex flex-col items-end gap-1 text-right">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/70">Last Contact</span>
<div className="flex items-center gap-1.5">
<Activity className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-xs font-bold uppercase tracking-tight">
{formatDateLastContact(agent.lastContact)}
</span>
</div>
</div>
<div className="scale-110">
<ConnectionCircle date={agent.lastContact}/>
</div>
<ChevronRight className="w-5 h-5 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all" />
</div>
</Card>
</Link>
@@ -5,7 +5,7 @@ import {Server} from "lucide-react";
import {formatDateLastContact} from "@/utils/date-formatting";
import {AgentCardKey} from "@/components/wrappers/dashboard/agent/agent-card-key/agent-card-key";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {DatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
import {AgentDatabaseCard} from "@/components/wrappers/dashboard/agent/agent-database-card";
import {AgentWithDatabases} from "@/db/schema/08_agent";
import {eventUpdate} from "@/types/events";
import {useAutoRefresh} from "@/hooks/use-auto-refresh";
@@ -72,7 +72,7 @@ export const AgentContentPage = ({edgeKey, agent}: AgentContentPageProps) => {
</CardContent>
</Card>
<CardsWithPagination cardsPerPage={4} numberOfColumns={2} data={agent.databases}
cardItem={DatabaseCard}/>
cardItem={AgentDatabaseCard}/>
</>
)
@@ -0,0 +1,26 @@
"use client";
import {Database} from "@/db/schema/07_database";
import {DatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
export type agentDatabaseCardProps = {
data: Database;
};
export const AgentDatabaseCard = (props: agentDatabaseCardProps) => {
const {data: database} = props;
return <DatabaseCard withDetails={false} data={database}/>;
//Todo: Add again when impersonation system will be implemented
/* if (!database.projectId) {
return <DatabaseCard data={database}/>;
}
return (
<Link className="group block transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
href={`/dashboard/projects/${database.projectId}/database/${database.id}`}>
<DatabaseCard data={database}/>
</Link>
);*/
};
@@ -47,7 +47,6 @@ export const AgentForm = (props: agentFormProps) => {
const data = createAgent?.data?.data;
if (createAgent?.serverError || !data) {
console.log(createAgent?.serverError);
toast.error(createAgent?.serverError);
return;
}
@@ -58,54 +57,53 @@ export const AgentForm = (props: agentFormProps) => {
});
return (
<TooltipProvider>
<Card>
<CardContent>
<Form
form={form}
className="flex flex-col gap-4 mt-3"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Agent 1" {...field} />
</FormControl>
<FormDescription>Your agent project name</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
defaultValue=""
name="description"
render={({field}) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input placeholder="This agent is for the client exemple.com" {...field}
value={field.value ?? ""}/>
</FormControl>
<FormDescription>Enter your project agent description</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<Button>{isCreate ? `Create agent` : `Save agent`}</Button>
</Form>
</CardContent>
</Card>
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Agent 1" {...field} />
</FormControl>
<FormDescription>Your agent project name</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
defaultValue=""
name="description"
render={({field}) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input placeholder="This agent is for the client exemple.com" {...field}
value={field.value ?? ""}/>
</FormControl>
<FormDescription>Enter your project agent description</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit">
{isCreate ? "Create" : "Update"}
</Button>
</div>
</Form>
</TooltipProvider>
);
};
@@ -6,6 +6,7 @@ import {useMutation} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {toast} from "sonner";
import {deleteAgentAction} from "@/components/wrappers/dashboard/agent/button-delete-agent/delete-agent.action";
import {useIsMobile} from "@/hooks/use-mobile";
export type ButtonDeleteAgentProps = {
text?: string;
@@ -14,6 +15,7 @@ export type ButtonDeleteAgentProps = {
export const ButtonDeleteAgent = (props: ButtonDeleteAgentProps) => {
const router = useRouter();
const isMobile = useIsMobile();
const mutation = useMutation({
mutationFn: () => deleteAgentAction(props.agentId),
@@ -33,7 +35,7 @@ export const ButtonDeleteAgent = (props: ButtonDeleteAgentProps) => {
description="Are you sure you want to remove this agent? This action cannot be undone."
button={{
main: {
text: props.text ? props.text : "",
text: props.text ? !isMobile ? props.text: "" : "",
variant: "outline",
icon: <Trash2 color="red"/>,
},
@@ -9,6 +9,8 @@ import {
import {EditNotifierButton} from "@/components/wrappers/dashboard/common/notifier/notifier-card/button-edit-notifier";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {getNotificationChannelIcon} from "@/components/wrappers/dashboard/admin/notifications/helpers";
import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile";
export type NotifierCardProps = {
data: NotificationChannelWith;
@@ -19,6 +21,7 @@ export type NotifierCardProps = {
export const NotifierCard = (props: NotifierCardProps) => {
const {data, organization} = props;
const isMobile = useIsMobile()
return (
<div className="block transition-all duration-200 rounded-xl">
@@ -33,13 +36,14 @@ export const NotifierCard = (props: NotifierCardProps) => {
<div className="flex justify-start w-full">
<div className="flex flex-col items-start md:flex-row md:items-center gap-2 ">
<h3 className="font-medium text-foreground">{data.name}</h3>
<h3 className="font-medium text-foreground">{isMobile ? truncateWords(data.name, 2) : data.name}</h3>
<Badge variant="secondary" className="text-xs font-mono">
{data.provider}
</Badge>
</div>
</div>
<div className="flex items-center gap-2">
<EditNotifierButton
organizations={props.organizations}
@@ -7,11 +7,11 @@ export const NotificationChannelFormSchema = z.object({
.min(5, "Name must be at least 5 characters long")
.max(40, "Name must be at most 40 characters long"),
provider: z.enum(["slack", "smtp"], {
provider: z.enum(["slack", "smtp", "discord", "telegram", "gotify", "ntfy", "webhook"], {
required_error: "Provider is required",
}),
config: z.record(z.string()).optional(),
config: z.record(z.union([z.string(), z.number(), z.boolean(), z.null(), z.undefined()])).optional(),
enabled: z.boolean().default(true),
@@ -8,7 +8,6 @@ import {Input} from "@/components/ui/input";
import {
NotificationChannelFormSchema, NotificationChannelFormType
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.schema";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {
addNotificationChannelAction, updateNotificationChannelAction
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action";
@@ -20,12 +19,31 @@ import {
import {
NotifierSlackForm
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-slack.form";
import {
NotifierDiscordForm
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-discord.form";
import {
NotifierTelegramForm
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-telegram.form";
import {
NotifierGotifyForm
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-gotify.form";
import {
NotifierNtfyForm
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-ntfy.form";
import {
NotifierWebhookForm
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-webhook.form";
import {Button} from "@/components/ui/button";
import {NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {
NotifierTestChannelButton
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-test-channel-button";
import {useEffect} from "react";
import {notificationTypes} from "@/components/wrappers/dashboard/admin/notifications/helpers";
import {cn} from "@/lib/utils";
import {Card} from "@/components/ui/card";
import {ArrowLeft} from "lucide-react";
type NotifierFormProps = {
onSuccessAction?: () => void;
@@ -34,7 +52,7 @@ type NotifierFormProps = {
adminView?: boolean
};
export const NotifierForm = ({onSuccessAction, organization, defaultValues, adminView}: NotifierFormProps) => {
export const NotifierForm = ({onSuccessAction, organization, defaultValues}: NotifierFormProps) => {
const router = useRouter();
const isCreate = !Boolean(defaultValues);
@@ -75,10 +93,36 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues, admi
});
const provider = form.watch("provider");
const selectedProviderDetails = notificationTypes.find(t => t.value === provider);
if (isCreate && !provider) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 py-4">
{notificationTypes.map((type) => {
const Icon = type.icon;
return (
<Card
key={type.value}
className={cn(
"flex flex-col items-center justify-center gap-3 p-4 cursor-pointer hover:bg-accent/50 hover:border-primary/50 transition-all",
)}
onClick={() => {
form.setValue("provider", type.value as any);
form.setValue("config", {});
}}
>
<div className="h-10 w-10 bg-secondary rounded-full flex items-center justify-center">
<Icon className="h-6 w-6 text-foreground"/>
</div>
<span className="font-medium text-sm">{type.label}</span>
</Card>
);
})}
</div>
);
}
return (
<Form
form={form}
className="flex flex-col gap-4"
@@ -86,6 +130,32 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues, admi
await mutationAddNotificationChannel.mutateAsync(values);
}}
>
<div className="flex items-center gap-3 mb-2 p-3 bg-secondary/30 rounded-lg border border-border">
{selectedProviderDetails && (
<div className="h-10 w-10 bg-background rounded-full flex items-center justify-center border border-border shadow-sm">
<selectedProviderDetails.icon className="h-5 w-5"/>
</div>
)}
<div className="flex-1">
<p className="text-sm font-medium">Configuring {selectedProviderDetails?.label}</p>
<p className="text-xs text-muted-foreground">{isCreate ? "New Channel" : "Edit Channel"}</p>
</div>
{isCreate && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
form.setValue("provider", undefined as any);
form.setValue("config", undefined);
}}
>
<ArrowLeft className="h-4 w-4 mr-2"/>
Change
</Button>
)}
</div>
<FormField
control={form.control}
name="name"
@@ -93,7 +163,7 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues, admi
<FormItem>
<FormLabel>Channel Name</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g., Primary email, Team Slack" value={field.value ?? ""}/>
<Input {...field} placeholder={`My ${selectedProviderDetails?.label} Channel`} value={field.value ?? ""}/>
</FormControl>
<FormMessage/>
</FormItem>
@@ -104,28 +174,7 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues, admi
control={form.control}
name="provider"
render={({field}) => (
<FormItem>
<FormLabel>Provider</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
form.setValue("config", {});
field.onChange(value);
}}
value={field.value || ""}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select provider"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="smtp">SMTP (Email)</SelectItem>
<SelectItem value="slack">Slack</SelectItem>
{/*<SelectItem value="curl">Curl</SelectItem>*/}
{/*<SelectItem value="webhook">Webhook</SelectItem>*/}
</SelectContent>
</Select>
</FormControl>
<FormMessage/>
</FormItem>
<input type="hidden" {...field} value={field.value || ""} />
)}
/>
@@ -137,8 +186,28 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues, admi
<NotifierSlackForm form={form}/>
)}
{provider === "discord" && (
<NotifierDiscordForm form={form}/>
)}
<div className="flex justify-between">
{provider === "telegram" && (
<NotifierTelegramForm form={form}/>
)}
{provider === "gotify" && (
<NotifierGotifyForm form={form}/>
)}
{provider === "ntfy" && (
<NotifierNtfyForm form={form}/>
)}
{provider === "webhook" && (
<NotifierWebhookForm form={form}/>
)}
<div className="flex justify-between mt-4">
<div>
{defaultValues && (
<NotifierTestChannelButton organizationId={organization?.id}
@@ -164,4 +233,4 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues, admi
</div>
</Form>
);
};
};
@@ -20,15 +20,6 @@ export const NotifierTestChannelButton = ({notificationChannel, organizationId}:
const mutation = useMutation({
mutationFn: async () => {
// const payload: EventPayload = {
// title: 'Database Down',
// message: 'Primary DB instance is unreachable',
// level: 'critical',
// data: {host: 'db-prod-01', error: 'connection timeout'},
// };
console.log("organizationId ici", organizationId);
const payload: EventPayload = {
title: 'Test Channel',
message: `We are testing channel ${notificationChannel.name}`,
@@ -0,0 +1,30 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
type NotifierDiscordFormProps = {
form: UseFormReturn<any, any, any>
}
export const NotifierDiscordForm = ({form}: NotifierDiscordFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.discordWebhook"
render={({field}) => (
<FormItem>
<FormLabel>Discord Webhook URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://discord.com/api/webhooks/..."/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</>
)
}
@@ -0,0 +1,43 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
type NotifierGotifyFormProps = {
form: UseFormReturn<any, any, any>
}
export const NotifierGotifyForm = ({form}: NotifierGotifyFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.gotifyServerUrl"
render={({field}) => (
<FormItem>
<FormLabel>Gotify Server URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://gotify.example.com"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.gotifyAppToken"
render={({field}) => (
<FormItem>
<FormLabel>Application Token</FormLabel>
<FormControl>
<Input {...field} placeholder="A1b2C3d4E5f6G7h"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</>
)
}
@@ -0,0 +1,58 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
type NotifierNtfyFormProps = {
form: UseFormReturn<any, any, any>
}
export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.ntfyTopic"
render={({field}) => (
<FormItem>
<FormLabel>Topic Name</FormLabel>
<FormControl>
<Input {...field} placeholder="my-secret-topic"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.ntfyServerUrl"
render={({field}) => (
<FormItem>
<FormLabel>Server URL (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="https://ntfy.sh (default)"/>
</FormControl>
<p className="text-xs text-muted-foreground">Leave empty to use the official ntfy.sh server.</p>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.ntfyToken"
render={({field}) => (
<FormItem>
<FormLabel>Access Token (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="tk_..."/>
</FormControl>
<p className="text-xs text-muted-foreground">Only required for protected topics or self-hosted instances with auth.</p>
<FormMessage/>
</FormItem>
)}
/>
</>
)
}
@@ -0,0 +1,48 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
type NotifierTelegramFormProps = {
form: UseFormReturn<any, any, any>
}
export const NotifierTelegramForm = ({form}: NotifierTelegramFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.telegramBotToken"
render={({field}) => (
<FormItem>
<FormLabel>Telegram Bot Token</FormLabel>
<FormControl>
<Input {...field} placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.telegramChatId"
render={({field}) => (
<FormItem>
<FormLabel>Telegram Chat ID</FormLabel>
<FormControl>
<Input {...field} placeholder="-100123456789 or 123456789"/>
</FormControl>
<p className="text-xs text-muted-foreground">
You must start the conversation with the bot first (<strong>/start</strong>). <br/>
For groups, add the bot to the group. <br/>
You can use <a href="https://t.me/userinfobot" target="_blank" rel="noopener noreferrer" className="underline">@userinfobot</a> to find your ID.
</p>
<FormMessage/>
</FormItem>
)}
/>
</>
)
}
@@ -0,0 +1,65 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
type NotifierWebhookFormProps = {
form: UseFormReturn<any, any, any>
}
export const NotifierWebhookForm = ({form}: NotifierWebhookFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.webhookUrl"
render={({field}) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://example.com/api/webhook"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<div className="flex gap-4">
<div className="flex-1">
<FormField
control={form.control}
name="config.webhookSecretHeader"
render={({field}) => (
<FormItem>
<FormLabel>Header Name (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="X-Webhook-Secret"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</div>
<div className="flex-1">
<FormField
control={form.control}
name="config.webhookSecret"
render={({field}) => (
<FormItem>
<FormLabel>Secret Value (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="Secret value..."/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
If provided, the secret will be sent in the specified header (defaults to <code>X-Webhook-Secret</code>).
</p>
</>
)
}
@@ -1,4 +1,4 @@
import {Form, FormControl, FormField, FormItem, FormMessage, useZodForm} from "@/components/ui/form";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {InfoIcon, Plus, Trash2} from "lucide-react";
import {useFieldArray} from "react-hook-form";
import {
@@ -11,7 +11,6 @@ import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {Label} from "@/components/ui/label";
import {Button} from "@/components/ui/button";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {Separator} from "@/components/ui/separator";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select";
import {useMutation} from "@tanstack/react-query";
@@ -22,7 +21,10 @@ import {
} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy.action";
import {useRouter} from "next/navigation";
import {Switch} from "@/components/ui/switch";
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip";
import {getNotificationChannelIcon} from "@/components/wrappers/dashboard/admin/notifications/helpers";
import {Card} from "@/components/ui/card";
import Link from "next/link";
import {useIsMobile} from "@/hooks/use-mobile";
type AlertPolicyFormProps = {
onSuccess?: () => void;
@@ -33,12 +35,12 @@ type AlertPolicyFormProps = {
export const AlertPolicyForm = ({database, notificationChannels, organizationId, onSuccess}: AlertPolicyFormProps) => {
const router = useRouter()
const isMobile = useIsMobile()
const organizationNotificationChannels = notificationChannels.map(channel => channel.id) ?? [];
const formattedAlertPoliciesList = (alertPolicies: AlertPolicy[]) => {
return alertPolicies.filter((alertPolicy)=> organizationNotificationChannels.includes(alertPolicy.notificationChannelId)).map((alertPolicy) => ({
return alertPolicies.filter((alertPolicy) => organizationNotificationChannels.includes(alertPolicy.notificationChannelId)).map((alertPolicy) => ({
notificationChannelId: alertPolicy.notificationChannelId,
eventKinds: alertPolicy.eventKinds,
enabled: alertPolicy.enabled
@@ -48,11 +50,7 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
const form = useZodForm({
schema: AlertPoliciesSchema,
defaultValues: {
alertPolicies: database.alertPolicies && database.alertPolicies.length > 0 ? formattedAlertPoliciesList(database.alertPolicies) : [{
notificationChannelId: "",
eventKinds: [],
enabled: true,
}],
alertPolicies: database.alertPolicies && database.alertPolicies.length > 0 ? formattedAlertPoliciesList(database.alertPolicies) : [],
},
});
@@ -63,7 +61,7 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
const addAlertPolicy = () => {
append({id: "", eventKinds: [], enabled: true});
append({notificationChannelId: "", eventKinds: [], enabled: true});
}
const removeAlertPolicy = (index: number) => {
@@ -78,12 +76,6 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
const mutation = useMutation({
mutationFn: async ({alertPolicies}: AlertPoliciesType) => {
console.log(alertPolicies)
const defaultFormatedAlertPolicies = formattedAlertPoliciesList(database?.alertPolicies ?? []);
const alertPoliciesToAdd = alertPolicies?.filter(
@@ -97,15 +89,10 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
const alertPoliciesToUpdate = alertPolicies?.filter((alertPolicy) => {
const existing = defaultFormatedAlertPolicies.find((a) => a.notificationChannelId === alertPolicy.notificationChannelId);
return existing &&
(existing.eventKinds !== alertPolicy.eventKinds);
(existing.eventKinds !== alertPolicy.eventKinds || existing.enabled !== alertPolicy.enabled);
}) ?? [];
console.log("alertPoliciesToAdd", alertPoliciesToAdd)
console.log("alertPoliciesToRemove", alertPoliciesToRemove)
console.log("alertPoliciesToUpdate", alertPoliciesToUpdate)
const results = await Promise.allSettled([
alertPoliciesToAdd.length > 0
? createAlertPoliciesAction({
@@ -129,8 +116,6 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
: Promise.resolve(null),
]);
console.log(results);
const rejected = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
if (rejected) {
throw new Error(rejected.reason?.message || "Network or server error");
@@ -144,7 +129,6 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
);
console.log("failedActions", failedActions);
if (failedActions.length > 0) {
const firstError = failedActions[0].data.actionError;
const message = firstError?.message || "One or more operations failed";
@@ -155,7 +139,7 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
},
onSuccess: () => {
toast.success("Alert policies saved successfully");
// onSuccess?.();
//onSuccess?.();
router.refresh();
},
onError: (error: any) => {
@@ -167,160 +151,205 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
return (
<Form
form={form}
className="flex flex-col gap-4"
className="flex flex-col gap-6"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Alert Policies</Label>
<div>
<Label className="text-base font-medium">Configure Alerts</Label>
{!isMobile && (
<p className="text-xs text-muted-foreground mt-1">
Choose which channels receive notifications for specific events.
</p>
)}
</div>
<Button
disabled={
fields.length >= notificationChannels.length ||
notificationChannels.length === 0
}
type="button"
variant="outline" size="sm" onClick={addAlertPolicy}>
<Plus className="w-4 h-4 mr-2"/>
Add
size="sm"
className="h-8"
onClick={addAlertPolicy}>
<Plus className="w-4 h-4 mr-1.5"/>
Add Policy
</Button>
</div>
<div className="space-y-3 w-full">
{organizationNotificationChannels.length === 0 ? (
<div className="text-muted-foreground text-sm text-center border border-dashed rounded-lg p-4">
No alerts policy in organization
<div
className="flex flex-col items-center justify-center p-8 border border-dashed rounded-xl bg-muted/20 text-center gap-2">
<InfoIcon className="h-8 w-8 text-muted-foreground/50"/>
<p className="font-medium text-sm text-foreground">No notification channels</p>
<p className="text-xs text-muted-foreground max-w-xs">
Please <Link href={`/dashboard/settings`}
className="underline underline-offset-4 hover:text-primary transition-colors">configure
notification channels</Link> in your organization settings first.
</p>
</div>
) : fields.length === 0 ? (
<div className="text-muted-foreground text-sm text-center">
No alert policy, create one
<div
className="flex flex-col items-center justify-center p-8 border border-dashed rounded-xl bg-muted/20 text-center gap-2">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
<Plus className="h-4 w-4 text-primary"/>
</div>
<p className="font-medium text-sm text-foreground">No alert policies</p>
<p className="text-xs text-muted-foreground">
Click "Add Policy" to start receiving notifications.
</p>
</div>
) : (
fields.map((field, index) => (
<div key={field.id}>
<div className="flex w-full gap-3">
<div className="flex-1">
<FormField
control={form.control}
name={`alertPolicies.${index}.notificationChannelId`}
render={({field}) => {
const selectedIds = form
.watch("alertPolicies")
.map((a: AlertPolicyType) => a.notificationChannelId)
.filter(Boolean);
<div className="grid gap-4">
{fields.map((field, index) => (
<Card key={field.id} className="p-4 transition-all hover:border-primary/50 relative group min-w-0 overflow-hidden">
<div className="flex flex-col gap-4">
<div className="flex flex-row gap-2 items-start md:items-end flex-nowrap min-w-0 ">
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest pl-0.5">
Notification Channel
</Label>
<FormField
control={form.control}
name={`alertPolicies.${index}.notificationChannelId`}
render={({ field }) => {
const selectedIds = form
.watch("alertPolicies")
.map((a: AlertPolicyType) => a.notificationChannelId)
.filter(Boolean);
const availableNotificationChannels = notificationChannels.filter(
(channel) =>
channel.id.toString() === field.value?.toString() ||
!selectedIds.includes(channel.id.toString())
);
const availableNotificationChannels = notificationChannels.filter(
(channel) =>
channel.id.toString() === field.value?.toString() ||
!selectedIds.includes(channel.id.toString())
);
return (
<FormItem>
<Select onValueChange={field.onChange}
value={field.value?.toString() || ""}
>
const selectedChannel = notificationChannels.find(c => c.id === field.value);
return (
<FormItem className="space-y-0 min-w-0">
<Select onValueChange={field.onChange} value={field.value?.toString() || ""}>
<FormControl>
<SelectTrigger className="h-9 w-full bg-background border-input min-w-0">
<SelectValue placeholder="Select channel">
{selectedChannel && (
<div className="flex items-center gap-2 min-w-0 w-full">
<div className="flex items-center justify-center h-4 w-4 shrink-0">
{getNotificationChannelIcon(selectedChannel.provider)}
</div>
<span className="truncate font-medium text-sm min-w-0">
{selectedChannel.name}
</span>
<span className="shrink-0 text-[9px] bg-secondary px-1.5 py-0.5 rounded text-muted-foreground font-mono uppercase">
{selectedChannel.provider}
</span>
</div>
)}
</SelectValue>
</SelectTrigger>
</FormControl>
<SelectContent>
{availableNotificationChannels.map(channel => (
<SelectItem key={channel.id.toString()} value={channel.id.toString()}>
<div className="flex items-center gap-2 w-full min-w-0">
<div className="text-muted-foreground scale-90 shrink-0">
{getNotificationChannelIcon(channel.provider)}
</div>
<span className="font-medium truncate min-w-0">{channel.name}</span>
<span className="text-xs text-muted-foreground ml-2 capitalize shrink-0">
({channel.provider})
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage className="mt-1"/>
</FormItem>
);
}}
/>
</div>
<div className="flex flex-col gap-1.5 shrink-0">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest pl-0.5">
Status
</Label>
<FormField
control={form.control}
name={`alertPolicies.${index}.enabled`}
render={({ field }) => (
<FormItem className="space-y-0">
<FormControl>
<SelectTrigger className="h-10 w-full ">
<SelectValue
placeholder="Select notification channel"/>
</SelectTrigger>
<div className="flex items-center h-9 px-1 md:px-3 rounded-md border border-input bg-background justify-between min-w-0">
{!isMobile && (
<Label htmlFor={`switch-${index}`} className="text-xs cursor-pointer font-medium text-foreground mr-2">
{field.value ? "Active" : "Off"}
</Label>
)}
<Switch checked={field.value} onCheckedChange={field.onChange} id={`switch-${index}`} className="scale-75 origin-right"/>
</div>
</FormControl>
<SelectContent>
{availableNotificationChannels.map((channel) => (
<SelectItem key={channel.id.toString()}
value={channel.id.toString()}>
{channel.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage className="mb-2"/>
</FormItem>
);
}}
/>
</FormItem>
)}
/>
</div>
<div className="flex flex-col gap-1.5 shrink-0 mt-auto">
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-destructive hover:border-destructive/50 hover:bg-destructive/10 transition-colors border-input bg-background"
onClick={() => removeAlertPolicy(index)}
>
<Trash2 className="w-4 h-4"/>
</Button>
</div>
</div>
<FormField
control={form.control}
name={`alertPolicies.${index}.eventKinds`}
render={({field}) => (
<FormItem>
render={({ field }) => (
<FormItem className="space-y-1.5 min-w-0">
<FormLabel className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Trigger Events
</FormLabel>
<FormControl>
<MultiSelect
options={EVENT_KIND_OPTIONS}
onValueChange={field.onChange}
defaultValue={field.value ?? []}
placeholder="Select event kinds"
variant="inverted"
animation={0}
/>
<div className="max-w-full overflow-hidden">
<MultiSelect
options={EVENT_KIND_OPTIONS}
onValueChange={field.onChange}
defaultValue={field.value ?? []}
placeholder={isMobile ? "Select events...": "Select events to trigger notifications..."}
variant="inverted"
animation={0}
className="bg-background/50 w-full min-w-0 flex-wrap"
/>
</div>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</div>
<div className="flex flex-col gap-3 justify-between items-center ">
<Button type="button" variant="outline"
onClick={() => removeAlertPolicy(index)}>
<Trash2 className="w-4 h-4"/>
</Button>
<div className="flex h-full justify-center items-center">
<Tooltip>
<TooltipTrigger asChild>
<div tabIndex={0} className="inline-flex rounded-md">
<FormField
control={form.control}
name={`alertPolicies.${index}.enabled`}
render={({field}) => (
<FormItem>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</TooltipTrigger>
<TooltipContent className="max-w-64 text-pretty">
<div className="flex items-center gap-1.5">
<InfoIcon className="size-4"/>
<p>This is for activating the alert policy</p>
</div>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
{index + 1 < fields.length && <Separator className="mt-3"/>}
</div>
))
</Card>
))}
</div>
)}
</div>
</div>
<div className="flex gap-4 justify-end">
<div className="flex gap-3 justify-end pt-2 border-t mt-2">
<ButtonWithLoading variant="outline" type="button" onClick={onCancel}>
Cancel
</ButtonWithLoading>
<ButtonWithLoading isPending={false}>
Save
<ButtonWithLoading isPending={mutation.isPending}>
Save Changes
</ButtonWithLoading>
</div>
</Form>
);
};
};
@@ -42,6 +42,7 @@ export const uploadBackupAction = userAction
const arrayBuffer = await file.arrayBuffer();
const fileSize = file.size;
const uuid = uuidv4();
const fileName = `imported_${uuid}${fileExtension}`;
const buffer = Buffer.from(arrayBuffer);
@@ -85,6 +86,7 @@ export const uploadBackupAction = userAction
status: 'success',
databaseId: database.id,
file: fileName,
fileSize: fileSize,
})
.returning();
@@ -38,7 +38,6 @@ export function CreateOrganizationModal({
const mutation = useMutation({
mutationFn: async (values: OrganizationSchema) => {
console.log(values);
const result = await createOrganizationAction(values);
@@ -73,7 +72,6 @@ export function CreateOrganizationModal({
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
console.log(values);
await mutation.mutateAsync(values);
}}
>
@@ -6,6 +6,7 @@ import {useRouter} from "next/navigation";
import {toast} from "sonner";
import {authClient} from "@/lib/auth/auth-client";
import {Trash2} from "lucide-react";
import {useIsMobile} from "@/hooks/use-mobile";
export type DeleteOrganizationButtonProps = {
organizationSlug: string;
@@ -13,6 +14,7 @@ export type DeleteOrganizationButtonProps = {
export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) => {
const router = useRouter();
const isMobile = useIsMobile();
const {data: organizations, refetch} = authClient.useListOrganizations();
const mutation = useMutation({
@@ -47,7 +49,7 @@ export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) =
description="Are you sure you want to remove this organization? This action cannot be undone."
button={{
main: {
text: "Delete Organization",
text: !isMobile ? "Delete Organization" : "",
variant: "outline",
icon: <Trash2 color="red"/>,
},
@@ -1,6 +1,5 @@
"use client";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {
FormControl,
FormDescription,
@@ -22,7 +21,6 @@ import {
} from "@/components/wrappers/dashboard/organization/organization-form/organization-form.schema";
import {MemberWithUser, OrganizationWithMembers} from "@/db/schema/03_organization";
import {
deleteOrganizationAction,
updateOrganizationAction
} from "@/components/wrappers/dashboard/organization/organization.action";
import {toast} from "sonner";
@@ -95,76 +93,75 @@ export const OrganizationForm = (props: organizationFormProps) => {
return (
<Card>
<CardHeader></CardHeader>
<CardContent>
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Organization 1" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input
placeholder="project-1"
{...field}
onChange={(e) => {
const value = e.target.value.replaceAll(" ", "-").toLowerCase();
field.onChange(value);
}}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="users"
render={({field}) => (
<FormItem>
<FormLabel>Users</FormLabel>
<FormControl>
<MultiSelect
options={formatUsersList(props.users)}
onValueChange={field.onChange}
defaultValue={field.value ?? []}
placeholder="Select users"
variant="inverted"
animation={2}
// maxCount={100}
/>
</FormControl>
<FormDescription>Select users you want to add to this organization</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<Button>{isCreate ? `Create Organization` : `Update Organization`}</Button>
</Form>
</CardContent>
</Card>
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Organization 1" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input
placeholder="project-1"
{...field}
onChange={(e) => {
const value = e.target.value.replaceAll(" ", "-").toLowerCase();
field.onChange(value);
}}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="users"
render={({field}) => (
<FormItem>
<FormLabel>Users</FormLabel>
<FormControl>
<MultiSelect
options={formatUsersList(props.users)}
onValueChange={field.onChange}
defaultValue={field.value ?? []}
placeholder="Select users"
variant="inverted"
animation={2}
// maxCount={100}
/>
</FormControl>
<FormDescription>Select users you want to add to this organization</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit">
{isCreate ? "Create" : "Update"}
</Button>
</div>
</Form>
);
};
@@ -28,10 +28,6 @@ export const OrganizationNotifiersTab = ({
<h3 className="text-xl font-semibold text-balance mb-1">
Notification Settings
</h3>
<p className="text-muted-foreground leading-relaxed ">
Configure how and when you receive alerts about your services and
infrastructure.
</p>
</div>
<NotifierAddEditModal
organization={organization}
@@ -1,12 +1,12 @@
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { UploadIcon } from "lucide-react";
import { toast } from "sonner";
import { uploadImageAction } from "@/features/upload/public/upload.action";
import { useMutation } from "@tanstack/react-query";
import { updateImageUserAction } from "@/components/wrappers/dashboard/profile/actions/avatar.action";
import { useRouter } from "next/navigation";
import { User } from "@/db/schema/02_user";
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
import {UploadIcon} from "lucide-react";
import {toast} from "sonner";
import {uploadImageAction} from "@/features/upload/public/upload.action";
import {useMutation} from "@tanstack/react-query";
import {updateImageUserAction} from "@/components/wrappers/dashboard/profile/actions/avatar.action";
import {useRouter} from "next/navigation";
import {User} from "@/db/schema/02_user";
import React, {ChangeEvent} from "react";
export type AvatarWithUploadProps = {
@@ -25,7 +25,6 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
const data = uploadImage?.data?.data;
if (uploadImage?.serverError || !data) {
console.log(uploadImage?.serverError);
toast.error(uploadImage?.serverError);
return;
}
@@ -34,7 +33,6 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
const dataUser = updateUser?.data?.data;
if (updateUser?.serverError || !dataUser) {
console.log(updateUser?.serverError);
toast.error(updateUser?.serverError);
return;
}
@@ -55,12 +53,11 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
};
return (
<div className="relative ">
<Avatar className="w-24 h-24 lg:w-32 lg:h-32 border-4 border-muted/20">
<AvatarImage src={user.image || undefined}/>
<AvatarImage className="object-cover" src={user.image || undefined}/>
<AvatarFallback className="text-3xl">{user.name.charAt(0)}</AvatarFallback>
</Avatar>
@@ -75,7 +72,7 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
}}
className="cursor-pointer absolute inset-0 flex justify-center items-center opacity-0 transition-opacity hover:opacity-100 hover:bg-gray-500 hover:bg-opacity-50 rounded-full w-24 h-24 lg:w-32 lg:h-32"
>
<UploadIcon className="w-12 h-12 lg:w-16 lg:h-16 text-primary" />
<UploadIcon className="w-12 h-12 lg:w-16 lg:h-16 text-primary"/>
</div>
</div>
);
@@ -58,7 +58,6 @@ export function ProfileAccount({user}: ProfileAccountProps) {
router.refresh();
},
onError: (error: BetterAuthError) => {
console.log(error)
if (error.code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
toast.error("User already exists, use another email address!");
emailForm.reset({email: user.email});
@@ -8,6 +8,7 @@ import {
} from "@/components/wrappers/dashboard/projects/button-delete-project/delete-project.action";
import {useRouter} from "next/navigation";
import {toast} from "sonner";
import {useIsMobile} from "@/hooks/use-mobile";
export type ButtonDeleteProjectProps = {
text?: string;
@@ -16,6 +17,8 @@ export type ButtonDeleteProjectProps = {
export const ButtonDeleteProject = (props: ButtonDeleteProjectProps) => {
const router = useRouter();
const isMobile = useIsMobile()
const mutation = useMutation({
mutationFn: () => deleteProjectAction(props.projectId),
onSuccess: async (result: any) => {
@@ -35,7 +38,7 @@ export const ButtonDeleteProject = (props: ButtonDeleteProjectProps) => {
description="Are you sure you want to delete this project ? This action cannot be undone."
button={{
main: {
text: props.text ? props.text : "",
text: props.text ? !isMobile ? props.text: "" : "",
variant: "outline",
icon: <Trash2 color="red"/>,
},
@@ -21,8 +21,6 @@ export const deleteProjectAction = userAction.schema(z.string()).action(async ({
const databasesToRemove = databasesUpdated.map((db) => db.id);
console.log(databasesUpdated);
console.log(databasesToRemove);
await db.delete(drizzleDb.schemas.retentionPolicy)
.where(inArray(drizzleDb.schemas.retentionPolicy.databaseId, databasesToRemove)).execute();
@@ -125,7 +125,6 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={async () => {
console.log("Deleting rows:", rows)
await mutationDeleteBackups.mutateAsync(rows)
}}
className="text-red-600 focus:text-red-700"
@@ -1,8 +1,10 @@
"use client";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Card } from "@/components/ui/card";
import Link from "next/link";
import {ProjectWith} from "@/db/schema/06_project";
import {ChevronRight, Folder} from "lucide-react";
import {Badge} from "@/components/ui/badge";
export type projectCardProps = {
data: ProjectWith;
@@ -10,19 +12,39 @@ export type projectCardProps = {
};
export const ProjectCard = (props: projectCardProps) => {
const { data: project, organizationSlug } = props;
const { data: project } = props;
const dbCount = project.databases.length;
return (
<Link
href={`/dashboard/projects/${project.id}`}
className="block transition-all duration-200 hover:scale-[1.01] hover:shadow-md rounded-xl"
className="group block transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
>
<Card className="flex flex-row justify-between">
<div className="flex-1 text-left">
<CardHeader className="text-2xl font-bold">
<Card className="relative h-full flex flex-col p-4 transition-all border-border/50 bg-card hover:bg-accent/50 hover:border-primary/50 group-hover:shadow-lg overflow-hidden gap-0">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-primary/10 text-primary group-hover:bg-primary group-hover:text-primary-foreground transition-all duration-300 shadow-inner">
<Folder className="w-8 h-8" />
</div>
<Badge className="text-[10px] font-medium px-2 py-1 rounded-lg bg-secondary/50 text-foreground">{dbCount} {dbCount === 1 ? "Database" : "Databases"}
</Badge>
</div>
<div className="flex flex-col gap-2 flex-1">
<h3 className="text-lg font-black text-foreground group-hover:text-primary transition-colors line-clamp-1 tracking-tight">
{project.name}
</CardHeader>
<CardContent>{project.databases.length} databases</CardContent>
</h3>
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
Manage your databases and backup policies for this project.
</p>
</div>
<div className="mt-4 flex items-center justify-between pt-3 border-t border-border/50">
<span className="text-[10px] font-bold uppercase tracking-widest text-primary opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-x-[-10px] group-hover:translate-x-0">View Project</span>
<div className="flex items-center gap-1 text-muted-foreground group-hover:text-primary transition-colors">
<span className="text-[10px] font-bold uppercase tracking-widest group-hover:hidden">Details</span>
<ChevronRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform" />
</div>
</div>
</Card>
</Link>
@@ -2,11 +2,12 @@
import Link from "next/link";
import Image from "next/image";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {useState} from "react";
import {Card} from "@/components/ui/card";
import {ConnectionCircle} from "@/components/wrappers/common/connection-circle";
import {formatDateLastContact} from "@/utils/date-formatting";
import {Database} from "@/db/schema/07_database";
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
import {ChevronRight, Activity, Fingerprint, Copy, Check} from "lucide-react";
export type projectDatabaseCardProps = {
data: Database;
@@ -15,11 +16,12 @@ export type projectDatabaseCardProps = {
};
export const ProjectDatabaseCard = (props: projectDatabaseCardProps) => {
const {organizationSlug, data: database, extendedProps: extendedProps} = props;
const {data: database, extendedProps: extendedProps} = props;
return (
<Link className="block transition-all duration-200 hover:scale-[1.01] hover:shadow-md rounded-xl"
href={`/dashboard/projects/${extendedProps.id}/database/${database.id}`}>
<Link
className="group block transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
href={`/dashboard/projects/${extendedProps.id}/database/${database.id}`}>
<DatabaseCard data={database}/>
</Link>
);
@@ -27,27 +29,89 @@ export const ProjectDatabaseCard = (props: projectDatabaseCardProps) => {
export type databaseCardProps = {
data: Database;
withDetails?: boolean;
};
export const DatabaseCard = (props: databaseCardProps) => {
const {data: database} = props;
const {data: database, withDetails = true} = props;
const [isCopied, setIsCopied] = useState(false);
const handleCopy = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
navigator.clipboard.writeText(database.agentDatabaseId);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
};
return (
<Card className="flex flex-row justify-between space-x-2 ">
<div className="flex items-center justify-start w-full space-x-5 ">
<Image src={`/images/${database.dbms}.png`} alt="Database type Icon" width={60} height={60}
className="object-cover ml-4"/>
<div className="justify-between">
<div className="font-medium">Name: {database.name}</div>
<div className="text-sm text-muted-foreground">Generated Id: {database.agentDatabaseId}</div>
<div className="text-xs text-muted-foreground">
Last contact: {formatDateLastContact(database.lastContact)}
<Card
className="relative h-full flex flex-col p-4 transition-all border-border/50 bg-card hover:bg-accent/50 hover:border-primary/50 group-hover:shadow-lg overflow-hidden gap-0">
<div className="flex items-start justify-between mb-2">
<div
className="relative w-12 h-12 p-2 bg-background rounded-xl border border-border/50 shadow-sm flex items-center justify-center group-hover:border-primary/30 transition-all duration-300 group-hover:scale-105">
<Image
src={`/images/${database.dbms}.png`}
alt={`${database.dbms} icon`}
width={32}
height={32}
className="object-contain w-full h-full"
/>
</div>
<div className="flex flex-col items-end gap-3">
<div className="scale-100 origin-right">
<ConnectionCircle date={database.lastContact}/>
</div>
</div>
</div>
<div className="flex items-center px-5 justify-center">
<ConnectionCircle date={database.lastContact}/>
<div className="flex flex-col gap-2 flex-1">
<h3 className="text-lg font-black text-foreground group-hover:text-primary transition-colors truncate tracking-tight">
{database.name}
</h3>
<div className="flex flex-col gap-2">
<div
className="flex items-center gap-2 text-muted-foreground group-hover:text-foreground transition-colors">
<div className="p-1 bg-muted/50 rounded-lg">
<Fingerprint className="w-3.5 h-3.5"/>
</div>
<span className="font-mono text-[10px] font-bold truncate">
{database.agentDatabaseId}
</span>
<button
onClick={handleCopy}
className="ml-1 p-0.5 hover:bg-muted rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 z-10"
title="Copy ID"
>
{isCopied ? <Check className="w-2.5 h-2.5 text-green-500"/> :
<Copy className="w-2.5 h-2.5"/>}
</button>
</div>
<div
className="flex items-center gap-2 text-muted-foreground group-hover:text-foreground transition-colors">
<div className="p-1 bg-muted/50 rounded-lg">
<Activity className="w-3.5 h-3.5"/>
</div>
<span className="text-[10px] font-bold uppercase tracking-tight">
{formatDateLastContact(database.lastContact)}
</span>
</div>
</div>
</div>
{withDetails && (
<div className="mt-4 flex items-center justify-between pt-3 border-t border-border/50">
<span
className="text-[10px] font-black uppercase tracking-widest text-primary opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-x-[-10px] group-hover:translate-x-0">Open</span>
<div
className="flex items-center gap-1 text-muted-foreground group-hover:text-primary transition-colors">
<span
className="text-[10px] font-bold uppercase tracking-widest group-hover:hidden">Details</span>
<ChevronRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform"/>
</div>
</div>
)}
</Card>
);
};
@@ -62,7 +62,6 @@ export const createProjectAction = userAction
},
};
} catch (error) {
console.log(error);
return {
success: false,
actionError: {
@@ -78,9 +78,6 @@ export const ProjectForm = (props: projectFormProps) => {
});
return (
<Card>
<CardHeader></CardHeader>
<CardContent>
<Form
form={form}
className="flex flex-col gap-4"
@@ -124,9 +121,11 @@ export const ProjectForm = (props: projectFormProps) => {
</FormItem>
)}
/>
<Button>{isCreate ? `Create Project` : `Update Project`}</Button>
<div className="flex justify-end">
<Button type="submit">
{isCreate ? "Create" : "Update"}
</Button>
</div>
</Form>
</CardContent>
</Card>
);
};
@@ -0,0 +1,2 @@
ALTER TYPE "public"."provider_kind" ADD VALUE 'discord';--> statement-breakpoint
ALTER TYPE "public"."provider_kind" ADD VALUE 'telegram';
@@ -0,0 +1,2 @@
ALTER TYPE "public"."provider_kind" ADD VALUE 'gotify';--> statement-breakpoint
ALTER TYPE "public"."provider_kind" ADD VALUE 'ntfy';
@@ -0,0 +1 @@
ALTER TYPE "public"."provider_kind" ADD VALUE 'webhook';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -120,6 +120,27 @@
"when": 1767363779637,
"tag": "0016_broken_morgan_stark",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1767778503470,
"tag": "0017_wild_purple_man",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1767780819624,
"tag": "0018_smiling_mole_man",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1767782077775,
"tag": "0019_overjoyed_butterfly",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -7,7 +7,7 @@ import {z} from "zod";
import {OrganizationInvitation} from "@/db/schema/05_invitation";
export const providerKindEnum = pgEnum('provider_kind', ['slack', 'smtp']);
export const providerKindEnum = pgEnum('provider_kind', ['slack', 'smtp', 'discord', 'telegram', 'gotify', 'ntfy', 'webhook']);
export const notificationChannel = pgTable('notification_channel', {
id: uuid("id").defaultRandom().primaryKey(),
@@ -66,10 +66,8 @@ export function backupColumns(
header: "Reference",
cell: ({row}) => {
const fileName = row.original.file
console.log(row.original)
const reference = row.original.id
const isImported = isImportedFilename(`${fileName}`)
console.log(isImported)
return isImported ? `${reference} (imported)` : `${reference}`
},
},
@@ -77,7 +75,6 @@ export function backupColumns(
accessorKey: "fileSize",
header: "Size",
cell: ({row}) => {
console.log(row.original.fileSize)
return formatBytes(row.getValue("fileSize"))
},
},
@@ -163,7 +160,6 @@ export function backupColumns(
} else if (settings.storage == "s3") {
data = await getFileUrlPreSignedS3Action(`backups/${database.project?.slug}/${fileName}`);
}
console.log(data)
if (data?.data?.success) {
url = data.data.value ?? "";
} else {
@@ -7,12 +7,9 @@ import * as drizzleDb from "@/db";
import {db} from "@/db";
import {and, eq} from "drizzle-orm";
import {Backup, Restoration} from "@/db/schema/07_database";
import {NextResponse} from "next/server";
import {
deleteFileS3Private,
deleteLocalPrivate,
uploadLocalPrivate,
uploadS3Private
} from "@/features/upload/private/upload.action";
import {env} from "@/env.mjs";
import {withUpdatedAt} from "@/db/utils";
@@ -89,98 +86,32 @@ export const deleteBackupAction = userAction
let success: boolean, message: string;
const result =
settings.storage === "local"
? await deleteLocalPrivate(parsedInput.file)
: await deleteFileS3Private(`${parsedInput.projectSlug}/${parsedInput.file}`, env.S3_BUCKET_NAME!);
if (parsedInput.file) {
const result =
settings.storage === "local"
? await deleteLocalPrivate(parsedInput.file)
: await deleteFileS3Private(`${parsedInput.projectSlug}/${parsedInput.file}`, env.S3_BUCKET_NAME!);
({success, message} = result);
({success, message} = result);
if (!success) {
return {
success: false,
actionError: {
message: message,
status: 404,
cause: "Unable to delete backup from storage",
messageParams: {message: "Error deleting the backup"},
},
};
if (!success) {
return {
success: false,
actionError: {
message: message,
status: 404,
cause: "Unable to delete backup from storage",
messageParams: {message: "Error deleting the backup"},
},
};
}
}
return {
success: true,
actionSuccess: {
message: `Backup deleted successfully (ref: ${parsedInput.backupId}).`,
},
};
// const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
// if (!settings) {
// return {
// success: false,
// actionError: {
// message: "No settings found.",
// status: 404,
// cause: "No settings found.",
// messageParams: {message: "Error deleting the backup"},
// },
// };
// }
//
//
// let success: boolean, message: string;
//
// const result =
// settings.storage === "local"
// ? await deleteLocalPrivate(parsedInput.file)
// : await deleteFileS3Private(`${parsedInput.projectSlug}/${parsedInput.file}`, env.S3_BUCKET_NAME!);
//
// ({success, message} = result);
//
// if (!success) {
// return {
// success: false,
// actionError: {
// message: message,
// status: 404,
// cause: "Unable to delete backup from storage",
// messageParams: {message: "Error deleting the backup"},
// },
// };
// }
//
// await db
// .delete(drizzleDb.schemas.backup)
// .where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId)))
// .execute();
//
// const backupExists = await db
// .select()
// .from(drizzleDb.schemas.backup)
// .where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId)))
// .execute();
//
//
// if (backupExists.length === 0) {
// return {
// success: true,
// actionSuccess: {
// message: "Backup deleted successfully.",
// },
// };
// } else {
// return {
// success: false,
// actionError: {
// message: "Backup not found or already deleted.",
// status: 404,
// cause: "Backup could not be deleted (from database or remote storage).",
// messageParams: {message: "Error deleting the backup"},
// },
// };
// }
} catch (error) {
return {
success: false,
@@ -246,7 +177,6 @@ export const rerunRestorationAction = userAction
});
// Create Restoration Action (Drizzle version)
export const createRestorationAction = userAction
.schema(
z.object({
@@ -256,7 +186,6 @@ export const createRestorationAction = userAction
)
.action(async ({parsedInput}): Promise<ServerActionResult<Restoration>> => {
try {
// Insert new restoration into the database
const restorationData = await db
.insert(drizzleDb.schemas.restoration)
.values({
+1 -2
View File
@@ -7,7 +7,6 @@ import {db} from "@/db";
import {notificationLog} from "@/db/schema/11_notification-log";
import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {Json} from "drizzle-zod";
import {AlertPolicy} from "@/db/schema/10_alert-policy";
export async function dispatchNotification(
payload: EventPayload,
@@ -85,7 +84,7 @@ export async function dispatchNotification(
success: false,
channelId: channelId || "",
provider: null,
error: "Channel not active¬",
error: "Channel not active",
};
}
+59 -62
View File
@@ -1,67 +1,64 @@
import {DatabaseWith} from "@/db/schema/07_database";
import {EventKind, EventPayload} from "@/features/notifications/types";
import {dispatchNotification} from "@/features/notifications/dispatch";
import { DatabaseWith } from "@/db/schema/07_database";
import { EventKind, EventPayload } from "@/features/notifications/types";
import { dispatchNotification } from "@/features/notifications/dispatch";
export async function sendNotificationsBackupRestore(database: DatabaseWith, event: EventKind) {
if (database.alertPolicies && database.alertPolicies.length > 0) {
for (const alertPolicy of database.alertPolicies) {
if (alertPolicy.enabled) {
const eventKinds = alertPolicy.eventKinds
if (eventKinds.includes(event)) {
const date = new Date()
let level: "info" | "critical" = "info";
let message = "";
let error: string | null = null;
switch (event) {
case "error_backup":
case "error_restore":
level = "critical";
message = `An error occurred during ${event.includes("backup") ? "backup" : "restore"} on ${date.toISOString()}.`;
error = "Check database connection or agent";
break;
case "success_backup":
case "success_restore":
level = "info";
message = `${event.includes("backup") ? "Backup" : "Restore"} completed successfully at ${date.toISOString()}.`;
break;
case "weekly_report":
level = "info";
message = `Weekly report generated at ${date.toISOString()}.`;
break;
}
const titleMap: Record<EventKind, string> = {
error_backup: `Backup Notification`,
error_restore: `Restore Notification`,
success_backup: `Backup Notification`,
success_restore: `Restore Notification`,
weekly_report: `Weekly Report Notification`,
};
const payload: EventPayload = {
title: titleMap[event],
message,
level: level,
event: event,
data: {
host: database.name,
id: database.id,
agentDatabaseId: database.agentDatabaseId,
error,
},
};
return await dispatchNotification(payload, alertPolicy.id, undefined, undefined);
}
}
}
if (!database.alertPolicies || database.alertPolicies.length === 0) {
return [];
}
const activePolicies = database.alertPolicies.filter(policy =>
policy.enabled && policy.eventKinds.includes(event)
);
const promises = activePolicies.map(alertPolicy => {
const date = new Date();
let level: "info" | "critical" = "info";
let message = "";
let error: string | null = null;
switch (event) {
case "error_backup":
case "error_restore":
level = "critical";
message = `An error occurred during ${event.includes("backup") ? "backup" : "restore"} on ${date.toISOString()}.`;
error = "Check database connection or agent";
break;
case "success_backup":
case "success_restore":
level = "info";
message = `${event.includes("backup") ? "Backup" : "Restore"} completed successfully at ${date.toISOString()}.`;
break;
case "weekly_report":
level = "info";
message = `Weekly report generated at ${date.toISOString()}.`;
break;
}
const titleMap: Record<EventKind, string> = {
error_backup: `Backup Notification`,
error_restore: `Restore Notification`,
success_backup: `Backup Notification`,
success_restore: `Restore Notification`,
weekly_report: `Weekly Report Notification`,
};
const payload: EventPayload = {
title: titleMap[event],
message,
level: level,
event: event,
data: {
host: database.name,
id: database.id,
agentDatabaseId: database.agentDatabaseId,
error,
},
};
return dispatchNotification(payload, alertPolicy.id, undefined, undefined);
});
return Promise.all(promises);
}
@@ -0,0 +1,48 @@
import type {EventPayload, DispatchResult} from '../types';
export async function sendDiscord(
config: { discordWebhook: string },
payload: EventPayload
): Promise<DispatchResult> {
const {discordWebhook: webhookUrl} = config;
const embeds = [
{
title: `[${payload.level.toUpperCase()}] ${payload.title}`,
description: payload.message,
color: payload.level === 'critical' ? 15158332 : payload.level === 'warning' ? 16776960 : 3447003,
fields: payload.data ? [
{
name: 'Data',
value: `
${JSON.stringify(payload.data, null, 2).substring(0, 1000)}
`,
inline: false
}
] : [],
timestamp: new Date().toISOString(),
}
];
const body = {
embeds,
};
const res = await fetch(webhookUrl, {
method: 'POST',
body: JSON.stringify(body),
headers: {'Content-Type': 'application/json'},
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Discord error: ${res.status} ${err}`);
}
return {
success: true,
provider: 'discord',
message: 'Sent to Discord',
response: res.statusText,
};
}
@@ -0,0 +1,34 @@
import type {EventPayload, DispatchResult} from '../types';
export async function sendGotify(
config: { gotifyServerUrl: string; gotifyAppToken: string },
payload: EventPayload
): Promise<DispatchResult> {
const {gotifyServerUrl, gotifyAppToken} = config;
const baseUrl = gotifyServerUrl.replace(/\/$/, "");
const body = {
title: `[${payload.level.toUpperCase()}] ${payload.title}`,
message: `${payload.message}\n\n${payload.data ? `Data:\n${JSON.stringify(payload.data, null, 2)}` : ''}`,
priority: payload.level === 'critical' ? 8 : payload.level === 'warning' ? 5 : 2,
};
const res = await fetch(`${baseUrl}/message?token=${gotifyAppToken}`, {
method: 'POST',
body: JSON.stringify(body),
headers: {'Content-Type': 'application/json'},
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Gotify error: ${res.status} ${err}`);
}
return {
success: true,
provider: 'gotify',
message: 'Sent to Gotify',
response: await res.json(),
};
}
@@ -2,6 +2,11 @@
import type {ProviderKind, EventPayload, DispatchResult} from '../types';
import {sendSlack} from './slack';
import {sendSmtp} from './smtp';
import {sendDiscord} from "@/features/notifications/providers/discord";
import {sendTelegram} from "@/features/notifications/providers/telegram";
import {sendGotify} from "@/features/notifications/providers/gotify";
import {sendNtfy} from "@/features/notifications/providers/ntfy";
import {sendWebhook} from "@/features/notifications/providers/webhook";
const handlers: Record<
ProviderKind,
@@ -9,6 +14,11 @@ const handlers: Record<
> = {
slack: sendSlack,
smtp: sendSmtp,
discord: sendDiscord,
telegram: sendTelegram,
gotify: sendGotify,
ntfy: sendNtfy,
webhook: sendWebhook
};
export async function dispatchViaProvider(
@@ -0,0 +1,44 @@
import type {EventPayload, DispatchResult} from '../types';
export async function sendNtfy(
config: { ntfyServerUrl?: string; ntfyTopic: string; ntfyToken?: string },
payload: EventPayload
): Promise<DispatchResult> {
const {ntfyServerUrl, ntfyTopic, ntfyToken} = config;
const baseUrl = (ntfyServerUrl || "https://ntfy.sh").replace(/\/$/, "");
const body = {
topic: ntfyTopic,
title: payload.title,
message: payload.message + (payload.data ? `\n\nData:\n${JSON.stringify(payload.data, null, 2)}` : ''),
priority: 1,
tags: [payload.level === 'critical' ? 'rotating_light' : payload.level === 'warning' ? 'warning' : 'information_source'],
};
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (ntfyToken) {
headers['Authorization'] = `Bearer ${ntfyToken}`;
}
const res = await fetch(`${baseUrl}`, {
method: 'POST',
body: JSON.stringify(body),
headers: headers,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`ntfy error: ${res.status} ${err}`);
}
return {
success: true,
provider: 'ntfy',
message: 'Sent to ntfy',
response: await res.json(),
};
}
@@ -0,0 +1,49 @@
import type {EventPayload, DispatchResult} from '../types';
export async function sendTelegram(
config: { telegramBotToken: string; telegramChatId: string },
payload: EventPayload
): Promise<DispatchResult> {
const {telegramBotToken, telegramChatId} = config;
// Helper to escape HTML characters
const escapeHtml = (unsafe: string) => {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
};
const title = escapeHtml(payload.title);
const message = escapeHtml(payload.message);
const level = escapeHtml(payload.level.toUpperCase());
const dataString = payload.data ? escapeHtml(JSON.stringify(payload.data, null, 2).substring(0, 1000)) : '';
const text = `<b>${title}</b>\n\n${message}\n\nLevel: <code>${level}</code>${payload.data ? `\n\nData:\n<pre>${dataString}</pre>` : ''}`;
const body = {
chat_id: telegramChatId,
text,
parse_mode: 'HTML',
};
const res = await fetch(`https://api.telegram.org/bot${telegramBotToken}/sendMessage`, {
method: 'POST',
body: JSON.stringify(body),
headers: {'Content-Type': 'application/json'},
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Telegram error: ${res.status} ${err}`);
}
return {
success: true,
provider: 'telegram',
message: 'Sent to Telegram',
response: await res.text(),
};
}
@@ -0,0 +1,35 @@
import type {EventPayload, DispatchResult} from '../types';
export async function sendWebhook(
config: { webhookUrl: string; webhookSecret?: string; webhookSecretHeader?: string },
payload: EventPayload
): Promise<DispatchResult> {
const {webhookUrl, webhookSecret, webhookSecretHeader} = config;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': 'Portabase-Notifier/1.0'
};
if (webhookSecret) {
headers[webhookSecretHeader || 'X-Webhook-Secret'] = webhookSecret;
}
const res = await fetch(webhookUrl, {
method: 'POST',
body: JSON.stringify(payload),
headers: headers,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Webhook error: ${res.status} ${err}`);
}
return {
success: true,
provider: 'webhook',
message: 'Sent to Webhook',
response: await res.text(),
};
}
+1 -1
View File
@@ -1,4 +1,4 @@
export type ProviderKind = 'slack' | 'smtp';
export type ProviderKind = 'slack' | 'smtp' | 'discord' | 'telegram' | 'gotify' | 'ntfy' | 'webhook';
export interface DispatchResult {
success: boolean;
+2 -1
View File
@@ -75,8 +75,9 @@ export async function deleteFileS3Private(fileName: string, bucketName: string)
*/
export async function deleteLocalPrivate(fileName: string) {
try {
console.log(fileName);
const filePath = path.join(process.cwd(), privateLocalDir, fileName);
console.log(`Deleted ${filePath}`);
// Delete locally
await unlink(filePath);
+4 -5
View File
@@ -15,7 +15,7 @@ import {Setting} from "@/db/schema/01_setting";
import * as drizzleDb from "@/db";
const privateS3ImageDir = "images/";
const imageDir = "images/";
export const uploadImageAction = userAction
@@ -39,10 +39,10 @@ export const uploadImageAction = userAction
let result: void | UploadedObjectInfo;
if (settings.storage === "local") {
fileName = `${uuid}.${fileFormat}`;
fileName = `${imageDir}${uuid}.${fileFormat}`;
result = await uploadLocal(fileName, buffer);
} else if (settings.storage === "s3") {
fileName = `${privateS3ImageDir}${uuid}.${fileFormat}`;
fileName = `${imageDir}${uuid}.${fileFormat}`;
result = await uploadS3Compatible(env.S3_BUCKET_NAME ?? "", fileName, buffer);
} else {
throw new Error(`Unsupported storage type: ${settings.storage}`);
@@ -54,12 +54,11 @@ export const uploadImageAction = userAction
async function uploadLocal(fileName: string, buffer: any) {
const localDir = "private/uploads/images/";
const localDir = "private/uploads/";
try {
await mkdir(path.join(process.cwd(), localDir), {recursive: true});
return await writeFile(path.join(process.cwd(), localDir + fileName), buffer);
} catch (error) {
console.log("Error occured ", error);
throw new Error("An error occured while importing image");
}
}
+1 -4
View File
@@ -45,15 +45,12 @@ export function useAutoRefresh(options: UseAutoRefreshOptions) {
try {
payload = JSON.parse(event.data);
} catch {
// keep raw payload
}
} catch {}
const shouldRefresh =
options.sse?.shouldRefresh?.(payload) ?? true;
if (shouldRefresh) {
console.log("Received event source", eventSource);
router.refresh();
}
});
+4 -15
View File
@@ -46,11 +46,7 @@ export const auth = betterAuth({
{}
),
});
},
onPasswordReset: async ({user}, request) => {
console.log(`Password for user ${user.email} has been reset.`);
},
}
},
emailVerification: {
async sendVerificationEmail({user, token, url}) {
@@ -176,13 +172,12 @@ export const auth = betterAuth({
const role = userCount === 0 ? "owner" : "admin";
const defaultOrgSlug = "default"; // change this if your default org has a different slug
const defaultOrgSlug = "default";
const defaultOrg = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, defaultOrgSlug),
});
if (defaultOrg) {
console.log(user)
await db.insert(drizzleDb.schemas.member).values({
userId: user.id,
organizationId: defaultOrg.id,
@@ -295,7 +290,6 @@ export const auth = betterAuth({
};
}
console.log("sessionId", session.id);
const [aa] = await db
.update(drizzleUser.session)
@@ -303,7 +297,6 @@ export const auth = betterAuth({
.where(eq(drizzleUser.session.id, session.id))
.returning();
console.log("aaaaa", aa);
return {
...session,
@@ -516,9 +509,7 @@ export const checkSlugOrganization = async (slug: string) => {
});
return status;
} catch (e) {
console.log("err", e);
}
} catch {}
};
export const getActiveMember = async () => {
@@ -542,7 +533,5 @@ export const setActiveOrganization = async (slug: string) => {
organizationSlug: slug,
},
});
} catch (e) {
console.log("error", e);
}
} catch {}
};
-2
View File
@@ -18,7 +18,6 @@ export const retentionCleanTask = async () => {
},
},
});
console.log(databases);
for (const db of databases) {
if (!db.retentionPolicy) continue; // no policy = skip
@@ -34,7 +33,6 @@ export async function enforceRetention(
databaseId: string,
policy: typeof retentionPolicy.$inferSelect
) {
console.log(`EnforceRetention: ${policy}`);
switch (policy.type) {
case "count":
+20 -16
View File
@@ -43,27 +43,31 @@ export const deleteBackupCronAction = action
}))
.where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId)))
let success: boolean, message: string;
const result =
settings.storage === "local"
? await deleteLocalPrivate(parsedInput.file)
: await deleteFileS3Private(`${parsedInput.projectSlug}/${parsedInput.file}`, env.S3_BUCKET_NAME!);
if (parsedInput.file) {
let success: boolean, message: string;
({success, message} = result);
const result =
settings.storage === "local"
? await deleteLocalPrivate(parsedInput.file)
: await deleteFileS3Private(`${parsedInput.projectSlug}/${parsedInput.file}`, env.S3_BUCKET_NAME!);
if (!success) {
return {
success: false,
actionError: {
message: message,
status: 404,
cause: "Unable to delete backup from storage",
messageParams: {message: "Error deleting the backup"},
},
};
({success, message} = result);
if (!success) {
return {
success: false,
actionError: {
message: message,
status: 404,
cause: "Unable to delete backup from storage",
messageParams: {message: "Error deleting the backup"},
},
};
}
}
return {
success: true,
actionSuccess: {
-1
View File
@@ -8,7 +8,6 @@ export async function generateEdgeKey(serverUrl: string, agentId: string): Promi
agentId,
publicKey
};
console.log(edgeKeyData);
const edgeKeyJson = JSON.stringify(edgeKeyData);
const edgeKeyBuffer = Buffer.from(edgeKeyJson, 'utf-8');
return edgeKeyBuffer.toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
+1 -7
View File
@@ -171,22 +171,18 @@ export async function createPresignedUrlToUpload({
return await s3Client.presignedPutObject(bucketName, fileName, expiry);
}
// Function to create a bucket and make it public
export async function createPublicBucket({bucketName}: { bucketName: string }) {
const s3Client = await getS3Client();
try {
// Check if the bucket already exists
const exists = await s3Client.bucketExists(bucketName);
if (!exists) {
// Create the bucket
await s3Client.makeBucket(bucketName); // Change region if needed
await s3Client.makeBucket(bucketName);
console.log(`Bucket ${bucketName} created successfully.`);
} else {
console.log(`Bucket ${bucketName} already exists.`);
}
// Define a bucket policy for public access
const policy = {
Version: "2012-10-17",
Statement: [
@@ -199,7 +195,6 @@ export async function createPublicBucket({bucketName}: { bucketName: string }) {
],
};
// Set the policy to the bucket
await s3Client.setBucketPolicy(bucketName, JSON.stringify(policy));
console.log(`Bucket ${bucketName} is now public.`);
} catch (error) {
@@ -242,7 +237,6 @@ export async function createPresignedUrlToDownload({
bucketName,
fileName,
errorMessage: err?.message,
// stack: err?.stack,
});
throw {error: err.message ?? "Unknown error"};
}
-14896
View File
File diff suppressed because it is too large Load Diff