mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
918c6c448c | ||
|
|
1e2b4f16b3 | ||
|
|
f0d02eb8b3 | ||
|
|
96274206c5 | ||
|
|
7ae7ea6f79 | ||
|
|
487e7ad83d | ||
|
|
c9dabd39fe | ||
|
|
30ab689c6e | ||
|
|
8143af18af | ||
|
|
471c65cc48 | ||
|
|
9162d5afcc | ||
|
|
9236c664c2 | ||
|
|
767ed69020 | ||
|
|
93c706719f | ||
|
|
ed1ac1926b | ||
|
|
3adb6d01c0 | ||
|
|
9c195c1f30 | ||
|
|
e27093404b | ||
|
|
9602427ca0 | ||
|
|
46812cb5ff | ||
|
|
6258d92464 | ||
|
|
c57d913c46 | ||
|
|
5d285b7ac6 | ||
|
|
c96219f851 | ||
|
|
c7d949b1d1 | ||
|
|
6593a8e324 | ||
|
|
0988fa0115 | ||
|
|
9f0b1a172c | ||
|
|
0126a829dd | ||
|
|
b04a2750a3 | ||
|
|
9e22160b16 | ||
|
|
e8e4b093d2 | ||
|
|
0cb27f2637 | ||
|
|
1497c7385b | ||
|
|
327b06d2a1 | ||
|
|
2c2cfc0b73 | ||
|
|
93327b0a6a | ||
|
|
11c74d4117 | ||
|
|
f93bb6e33e | ||
|
|
73699bbda9 | ||
|
|
5f24ec2d86 | ||
|
|
c08bb8f8e0 | ||
|
|
d01e705866 | ||
|
|
ecc80c3c01 | ||
|
|
784a907384 | ||
|
|
b68a5f32ca | ||
|
|
467f9e2ffd | ||
|
|
1d25acde61 | ||
|
|
cb6ac86bb0 | ||
|
|
cd2da4441d | ||
|
|
d7515b172c | ||
|
|
e4de903cc9 | ||
|
|
45166a3b9c | ||
|
|
687b59e397 | ||
|
|
9a3be8d5a5 | ||
|
|
0c23c24651 | ||
|
|
e20e7f016c | ||
|
|
a0c1a24ba9 | ||
|
|
0475b3d01a | ||
|
|
39cb9b99f6 | ||
|
|
efd123ddfe | ||
|
|
413318886c | ||
|
|
187f9d2386 | ||
|
|
5f61c7baa6 | ||
|
|
e3c5ec9a58 | ||
|
|
fde52415cb | ||
|
|
d92225d093 | ||
|
|
e2a0932229 | ||
|
|
6acdf73557 | ||
|
|
4405b61e03 | ||
|
|
fba714d679 | ||
|
|
8e15282405 | ||
|
|
72b5816451 | ||
|
|
4016191497 | ||
|
|
94b36c1622 | ||
|
|
aa736b8b4a | ||
|
|
94cd937c56 | ||
|
|
9ccf3b7242 | ||
|
|
01b7a58c75 | ||
|
|
2b6c661fea | ||
|
|
fbd61cdf2f | ||
|
|
e9534181da | ||
|
|
fdc613fe8e | ||
|
|
fca09a84cb | ||
|
|
6ec6fb1f11 | ||
|
|
3d51d3548f | ||
|
|
8479b6c822 | ||
|
|
21bb165103 | ||
|
|
c7ca01c0fb | ||
|
|
5d2172f2bf | ||
|
|
1decd9d7a5 | ||
|
|
d0091cfed3 | ||
|
|
0ebcf1729c | ||
|
|
51d101e8f0 | ||
|
|
2d9bf225a0 | ||
|
|
f85d12448c | ||
|
|
82c8ad98b4 | ||
|
|
2499c7c426 | ||
|
|
da530217ab | ||
|
|
4c70d8aac7 | ||
|
|
ba1dbcc9f3 | ||
|
|
0ad380cc81 | ||
|
|
b77a2e374e | ||
|
|
647108e8ee | ||
|
|
8e4e41ca07 | ||
|
|
a8eb3f5f1a | ||
|
|
eb5ac5c8ea | ||
|
|
29344e0c48 | ||
|
|
73f7ac057c | ||
|
|
d359d53fb7 | ||
|
|
ff83ee9c62 | ||
|
|
da3a8d7fbe | ||
|
|
ba2fc55188 | ||
|
|
a6896c9b50 | ||
|
|
f2563c5e8d | ||
|
|
9326291780 | ||
|
|
8dd74f310d | ||
|
|
44ed9e9ca6 | ||
|
|
ab22680625 | ||
|
|
a73e29efc9 | ||
|
|
26273b7240 | ||
|
|
c925407f05 | ||
|
|
622fc369d0 | ||
|
|
0c4b7573cc | ||
|
|
401ce5c290 | ||
|
|
9580ad3934 | ||
|
|
b33c0810d5 | ||
|
|
e3e84e0d18 | ||
|
|
55cb4a7144 | ||
|
|
8fbe639aa1 | ||
|
|
9bc042e2cd | ||
|
|
67302c6a17 | ||
|
|
d7e2ec3b08 | ||
|
|
fdaca0e8a7 | ||
|
|
3af5f89606 | ||
|
|
3ccccfc5e5 | ||
|
|
7959f903bb | ||
|
|
59eb91f28f | ||
|
|
9aecfb22a5 | ||
|
|
ba4476a3a7 | ||
|
|
64724d2a92 | ||
|
|
be58ebdb7d | ||
|
|
746a9f5eb6 | ||
|
|
91a6d672fa | ||
|
|
50809476fa | ||
|
|
483f9cf192 | ||
|
|
f1c7dcda45 | ||
|
|
282ffb543c | ||
|
|
c840196a14 | ||
|
|
7d76037a43 | ||
|
|
68fea480ae | ||
|
|
b063e5c276 | ||
|
|
f2d98505f1 | ||
|
|
ec64ea8229 | ||
|
|
59dc181625 | ||
|
|
79387b83f6 | ||
|
|
b13433e554 | ||
|
|
d9fd382cb2 | ||
|
|
baa5e300c8 | ||
|
|
51747773ba | ||
|
|
4d324d885d | ||
|
|
af1a437523 | ||
|
|
dd8663591e | ||
|
|
de4f31f255 | ||
|
|
53c1bc9e59 | ||
|
|
77901ae1bc | ||
|
|
1aeff542e5 | ||
|
|
c05469d704 | ||
|
|
1e6bb542f9 | ||
|
|
b81ad079ee | ||
|
|
044edcda07 | ||
|
|
7eb85d2a99 | ||
|
|
7d4d6cd9df | ||
|
|
3f7eb4528e | ||
|
|
0008f70552 | ||
|
|
ff48517b43 | ||
|
|
2695715180 | ||
|
|
b3be1a561c | ||
|
|
cab7a7f8bb | ||
|
|
f6da5961c3 | ||
|
|
d297f1a27b | ||
|
|
b513509b01 | ||
|
|
bfa3f7e04e | ||
|
|
539b516e45 | ||
|
|
25744efc66 | ||
|
|
21e37366fb | ||
|
|
6002ad44e1 | ||
|
|
a431270718 | ||
|
|
3f6a47c39d | ||
|
|
4dace0a32b | ||
|
|
efb5318fc5 | ||
|
|
a5e83f3133 | ||
|
|
7b6ca12744 | ||
|
|
da1b5a969c | ||
|
|
5d790231ea | ||
|
|
ad363ce38c | ||
|
|
8e5d6eec6c | ||
|
|
92f1400a35 | ||
|
|
47f1938136 | ||
|
|
13d3cfb290 | ||
|
|
c657bdfb63 | ||
|
|
f226218b3a | ||
|
|
8f7fa0dea1 | ||
|
|
292b3c805d | ||
|
|
ded26f92af | ||
|
|
8cce7bbaf4 | ||
|
|
228b8c3ec7 | ||
|
|
2e298ce757 | ||
|
|
81daa9f0b0 | ||
|
|
713c8d9177 | ||
|
|
f571162eed | ||
|
|
19d2872943 | ||
|
|
0752b8bf7f | ||
|
|
c01097196a | ||
|
|
5f1bee018d | ||
|
|
8655b81618 | ||
|
|
1702e43373 | ||
|
|
a7d411f6ff | ||
|
|
6a84da0f96 | ||
|
|
79425f4490 | ||
|
|
2fcddea0fa | ||
|
|
ac1c8552e1 | ||
|
|
6a18eefe34 | ||
|
|
a43eedaf9f | ||
|
|
77bc24990e | ||
|
|
edbc79c89e | ||
|
|
527a090f1b | ||
|
|
370b86337f | ||
|
|
1ce3acdde0 | ||
|
|
1a72be323b | ||
|
|
bf252f22f2 | ||
|
|
937d92f686 | ||
|
|
5572957731 | ||
|
|
d2db5722b7 | ||
|
|
5404185a6c | ||
|
|
800f2dfbbb | ||
|
|
ed0b5410f6 | ||
|
|
ae72e262aa | ||
|
|
c4c7050c3f | ||
|
|
81e5227270 | ||
|
|
39319d9162 | ||
|
|
12206fa856 | ||
|
|
9d8c6e26f1 | ||
|
|
eb624672e6 | ||
|
|
1b026b5f08 | ||
|
|
21b9b8b44d | ||
|
|
6ef7fcc0c2 | ||
|
|
fc4ce178cb | ||
|
|
98b72988c4 | ||
|
|
317473e528 | ||
|
|
e13c5ddf51 | ||
|
|
90be3ab731 | ||
|
|
85c0445a64 | ||
|
|
6a227ccd7c | ||
|
|
3d9a21f854 | ||
|
|
d4229a985d | ||
|
|
c6e0921dea | ||
|
|
346a8a4375 | ||
|
|
4799faa827 | ||
|
|
2c6f5c38ed | ||
|
|
4a884ab5cc | ||
|
|
e59beaf749 | ||
|
|
0d4e1229ab | ||
|
|
8810fd91c9 | ||
|
|
095444610d | ||
|
|
7cf43c5e7c | ||
|
|
77183af08d | ||
|
|
3a2c5be5dd | ||
|
|
11c764c2f3 | ||
|
|
d128234ae6 | ||
|
|
ee31ee225d | ||
|
|
e2195616d3 | ||
|
|
2ca818b996 | ||
|
|
131f2b4538 | ||
|
|
6731c3fea9 | ||
|
|
d3da5e8c13 | ||
|
|
3b0d4225ce | ||
|
|
66f0930164 | ||
|
|
3979b83a0a | ||
|
|
b65e432564 | ||
|
|
340e7c3e00 | ||
|
|
dcdce63e15 | ||
|
|
c2eaed757d | ||
|
|
920743bea7 | ||
|
|
0970f5baae | ||
|
|
291aa13e9a | ||
|
|
e644cde514 | ||
|
|
2350e68f4f | ||
|
|
cce3b681e3 | ||
|
|
755799f19d | ||
|
|
ec02dc8579 | ||
|
|
7028982ba1 | ||
|
|
f04503e38e | ||
|
|
f1edffed17 | ||
|
|
fac87acee7 | ||
|
|
c46dd91ede | ||
|
|
648753f8cf | ||
|
|
76b3bda313 | ||
|
|
8102dd1a8f | ||
|
|
aceb4e7f4c | ||
|
|
4224389e96 | ||
|
|
7c66c10b6d | ||
|
|
bf06a2e50c | ||
|
|
8c69fdef95 | ||
|
|
883c1d2f57 | ||
|
|
e7f1652b4e | ||
|
|
ea07224a52 |
@@ -35,6 +35,11 @@ AUTH_OIDC_DISCOVERY_ENDPOINT=""
|
|||||||
AUTH_OIDC_JWKS_ENDPOINT=""
|
AUTH_OIDC_JWKS_ENDPOINT=""
|
||||||
AUTH_OIDC_PKCE=true
|
AUTH_OIDC_PKCE=true
|
||||||
ALLOWED_GROUP="admin"
|
ALLOWED_GROUP="admin"
|
||||||
|
AUTH_OIDC_ROLE_MAP=""
|
||||||
|
|
||||||
|
AUTH_ALLOW_LINKING=true
|
||||||
|
AUTH_ALLOW_UNLINKING=true
|
||||||
|
|
||||||
|
|
||||||
# Social OAuth2 Authentification
|
# Social OAuth2 Authentification
|
||||||
AUTH_SOCIAL_ID=""
|
AUTH_SOCIAL_ID=""
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ If you encounter a bug or have a suggestion for improvement, follow these steps:
|
|||||||
|
|
||||||
3. **Push your branch**
|
3. **Push your branch**
|
||||||
```bash
|
```bash
|
||||||
git push origin feature/<feature-name>
|
git push origin feat/<feature-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Open a Pull Request (PR)**
|
4. **Open a Pull Request (PR)**
|
||||||
@@ -82,7 +82,7 @@ If you encounter a bug or have a suggestion for improvement, follow these steps:
|
|||||||
|
|
||||||
## Code Style Guidelines
|
## Code Style Guidelines
|
||||||
|
|
||||||
- Follow the [specific coding style guide] (e.g., Prettier, ESLint, PEP8).
|
- Follow the [specific coding style guide] (e.g., Prettier, ESLint, PEP8,Biome).
|
||||||
- Use meaningful variable names and include comments where necessary.
|
- Use meaningful variable names and include comments where necessary.
|
||||||
- Tests before submitting your changes.
|
- Tests before submitting your changes.
|
||||||
|
|
||||||
@@ -107,4 +107,4 @@ If you encounter a bug or have a suggestion for improvement, follow these steps:
|
|||||||
|
|
||||||
Thank you for contributing! 🙌
|
Thank you for contributing! 🙌
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
name: Discord Notification
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
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:
|
||||||
|
notify-discord:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Send Discord Notification
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||||
|
run: |
|
||||||
|
RELEASE_INFO=$(gh release view "${{ inputs.release_tag }}" -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="${{ inputs.release_tag }}"; 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"
|
||||||
@@ -3,10 +3,13 @@ name: Docker Publish
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
|
version:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
image_name:
|
image_name:
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
default: 'portabase/portabase'
|
default: "portabase/portabase"
|
||||||
add_latest:
|
add_latest:
|
||||||
required: false
|
required: false
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -14,11 +17,11 @@ on:
|
|||||||
target:
|
target:
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
default: 'prod'
|
default: "prod"
|
||||||
dockerfile:
|
dockerfile:
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
default: './docker/dockerfile/Dockerfile'
|
default: "./docker/dockerfile/Dockerfile"
|
||||||
secrets:
|
secrets:
|
||||||
DOCKER_USERNAME:
|
DOCKER_USERNAME:
|
||||||
required: true
|
required: true
|
||||||
@@ -26,10 +29,9 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build and push Docker images
|
name: Build architectures
|
||||||
runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' }}
|
runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || 'ubuntu-24.04-arm' }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -38,22 +40,22 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to Docker
|
- name: Login to Docker
|
||||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Set image tag
|
- name: Prepare Image Tags
|
||||||
id: set-tags
|
id: prep
|
||||||
run: |
|
run: |
|
||||||
REF_NAME=${GITHUB_REF#refs/tags/}
|
ARCH=${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||||
if [ "${{ matrix.platform }}" = "linux/amd64" ]; then
|
echo "image=${{ inputs.image_name }}:${{inputs.version}}-$ARCH" >> $GITHUB_OUTPUT
|
||||||
IMAGE="${{ inputs.image_name }}:$REF_NAME-amd64"
|
echo "safe_platform=${{ matrix.platform == 'linux/amd64' && 'linux-amd64' || 'linux-arm64' }}" >> $GITHUB_OUTPUT
|
||||||
else
|
|
||||||
IMAGE="${{ inputs.image_name }}:$REF_NAME-arm64"
|
|
||||||
fi
|
|
||||||
echo "image=$IMAGE" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Build and push image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -62,61 +64,73 @@ jobs:
|
|||||||
file: ${{ inputs.dockerfile }}
|
file: ${{ inputs.dockerfile }}
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.set-tags.outputs.image }}
|
tags: ${{ steps.prep.outputs.image }}
|
||||||
target: ${{ inputs.target }}
|
target: ${{ inputs.target }}
|
||||||
|
cache-from: type=gha,scope=build-${{ matrix.platform }}
|
||||||
|
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
|
||||||
|
|
||||||
- name: Prepare artifact name
|
- name: Save image name for manifest
|
||||||
id: artifact
|
run: echo "${{ steps.prep.outputs.image }}" > image.txt
|
||||||
run: |
|
|
||||||
platform=${{ matrix.platform }}
|
|
||||||
echo "safe_platform=${platform//\//-}" >> $GITHUB_OUTPUT
|
|
||||||
echo "${{ steps.set-tags.outputs.image }}" > image.txt
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: image-${{ steps.artifact.outputs.safe_platform }}
|
name: image-${{ steps.prep.outputs.safe_platform }}
|
||||||
path: image.txt
|
path: image.txt
|
||||||
if-no-files-found: warn
|
retention-days: 1
|
||||||
compression-level: 6
|
|
||||||
overwrite: false
|
|
||||||
include-hidden-files: false
|
|
||||||
|
|
||||||
create-manifest:
|
create-manifest:
|
||||||
name: Create multi-arch Docker manifest (Portabase)
|
name: Create multi-arch manifest
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build
|
needs: build
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
|
- uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: image-linux-amd64
|
name: image-linux-amd64
|
||||||
path: /tmp/digests/amd64
|
path: /tmp/digests/amd64
|
||||||
|
|
||||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
|
- uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: image-linux-arm64
|
name: image-linux-arm64
|
||||||
path: /tmp/digests/arm64
|
path: /tmp/digests/arm64
|
||||||
|
|
||||||
- name: Login to Docker
|
- name: Login to Docker
|
||||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Generate semantic tags
|
||||||
|
id: tags
|
||||||
|
run: |
|
||||||
|
VERSION="${{ inputs.version }}"
|
||||||
|
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
|
||||||
|
|
||||||
|
echo "VERSION_TAG=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
if [ -n "$MINOR" ]; then
|
||||||
|
echo "MINOR_TAG=$MAJOR.$MINOR" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
if [ -n "$MAJOR" ]; then
|
||||||
|
echo "MAJOR_TAG=$MAJOR" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
if [ "${{ inputs.add_latest }}" = "true" ]; then
|
||||||
|
echo "LATEST_TAG=latest" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Extract Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ inputs.image_name }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=${{ steps.tags.outputs.VERSION_TAG }}
|
||||||
|
type=raw,value=${{ steps.tags.outputs.MINOR_TAG }}
|
||||||
|
type=raw,value=${{ steps.tags.outputs.MAJOR_TAG }}
|
||||||
|
type=raw,value=${{ steps.tags.outputs.LATEST_TAG }}
|
||||||
|
|
||||||
- name: Create and push manifest list
|
- name: Create and push manifest list
|
||||||
working-directory: /tmp/digests
|
working-directory: /tmp/digests
|
||||||
run: |
|
run: |
|
||||||
DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)"
|
DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)"
|
||||||
REF_NAME=${GITHUB_REF#refs/tags/}
|
TAG_ARGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")
|
||||||
MANIFEST_IMAGE="${{ inputs.image_name }}:$REF_NAME"
|
echo $TAG_ARGS
|
||||||
|
docker buildx imagetools create $TAG_ARGS $DOCKER_IMAGES
|
||||||
docker buildx imagetools create $DOCKER_IMAGES -t $MANIFEST_IMAGE
|
|
||||||
docker buildx imagetools inspect $MANIFEST_IMAGE
|
|
||||||
|
|
||||||
if [ "${{ inputs.add_latest }}" = "true" ]; then
|
|
||||||
docker buildx imagetools create $DOCKER_IMAGES -t ${{ inputs.image_name }}:latest
|
|
||||||
docker buildx imagetools inspect ${{ inputs.image_name }}:latest
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Publish Helm Chart
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
secrets:
|
||||||
|
GH_TOKEN:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-helm:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Helm
|
||||||
|
uses: azure/setup-helm@v4
|
||||||
|
|
||||||
|
- name: Package Helm chart
|
||||||
|
run: |
|
||||||
|
mkdir -p ./helm-packages
|
||||||
|
helm package helm \
|
||||||
|
--version ${{ inputs.version }} \
|
||||||
|
--app-version ${{ inputs.version }} \
|
||||||
|
--destination ./helm-packages
|
||||||
|
|
||||||
|
- name: Authenticate to GitHub Packages
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.GH_TOKEN }}" | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||||
|
|
||||||
|
- name: Push Helm chart to GitHub Packages (OCI)
|
||||||
|
run: |
|
||||||
|
helm push ./helm-packages/portabase-${{ inputs.version }}.tgz oci://ghcr.io/portabase/charts
|
||||||
@@ -3,29 +3,28 @@ name: Publish Docker image for release candidate
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '*-rc*'
|
- "*.*.*-rc*"
|
||||||
|
- "!*-*-rc*"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker_publish:
|
docker_publish:
|
||||||
uses: ./.github/workflows/docker.yml
|
uses: ./.github/workflows/docker.yml
|
||||||
with:
|
with:
|
||||||
add_latest: false
|
add_latest: true
|
||||||
secrets:
|
secrets:
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||||
|
|
||||||
github_release:
|
discord_notification:
|
||||||
needs: docker_publish
|
needs: docker_publish
|
||||||
uses: ./.github/workflows/github.yml
|
uses: ./.github/workflows/discord.yml
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
discord_title: "New Release Candidate: ${{ github.ref_name }}"
|
||||||
make_latest: false
|
discord_color: 2044800
|
||||||
discord_title: "||@release-dashboard|| New release candidate published"
|
|
||||||
discord_color: 16776960
|
|
||||||
discord_footer: "Portabase"
|
discord_footer: "Portabase"
|
||||||
secrets:
|
secrets:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
|||||||
+100
-13
@@ -1,32 +1,119 @@
|
|||||||
name: Publish Docker image for release
|
name: Auto Release & Publish
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
pull_request:
|
||||||
tags:
|
types: [ closed ]
|
||||||
- '*.*.*'
|
branches:
|
||||||
- '!*-*'
|
- main
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker_publish:
|
create-release:
|
||||||
|
if: github.event.pull_request.merged == true
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
draft_tag: ${{ steps.release_step.outputs.draft_tag }}
|
||||||
|
version: ${{ steps.release_step.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/create-github-app-token@v1
|
||||||
|
id: app-token
|
||||||
|
with:
|
||||||
|
app-id: ${{ vars.APP_ID }}
|
||||||
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "lts/*"
|
||||||
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- run: |
|
||||||
|
git config --global user.name 'github-actions[bot]'
|
||||||
|
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
|
||||||
|
|
||||||
|
|
||||||
|
- name: Run release-it
|
||||||
|
id: release_step
|
||||||
|
run: |
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
VERSION=$(pnpm exec release-it --ci --release-version)
|
||||||
|
echo $VERSION
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
OUTPUT=$(pnpm exec release-it --ci)
|
||||||
|
echo "$OUTPUT"
|
||||||
|
|
||||||
|
DRAFT_TAG=$(echo "$OUTPUT" | grep -oE 'untagged-[a-z0-9]+')
|
||||||
|
echo $DRAFT_TAG
|
||||||
|
echo "draft_tag=$DRAFT_TAG" >> $GITHUB_OUTPUT
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||||
|
|
||||||
|
|
||||||
|
publish-docker:
|
||||||
|
needs: create-release
|
||||||
uses: ./.github/workflows/docker.yml
|
uses: ./.github/workflows/docker.yml
|
||||||
with:
|
with:
|
||||||
|
version: ${{ needs.create-release.outputs.version }}
|
||||||
add_latest: true
|
add_latest: true
|
||||||
secrets:
|
secrets:
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||||
|
|
||||||
github_release:
|
publish-helm:
|
||||||
needs: docker_publish
|
needs: create-release
|
||||||
uses: ./.github/workflows/github.yml
|
uses: ./.github/workflows/helm.yml
|
||||||
with:
|
with:
|
||||||
prerelease: false
|
version: ${{ needs.create-release.outputs.version }}
|
||||||
make_latest: true
|
secrets:
|
||||||
discord_title: "||@release-dashboard|| New release published"
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
discord_color: 5814783
|
|
||||||
|
finalize-release:
|
||||||
|
needs:
|
||||||
|
- create-release
|
||||||
|
- publish-docker
|
||||||
|
- publish-helm
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
release_tag: ${{ steps.publish_release_step.outputs.release_tag }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Publish GitHub Release
|
||||||
|
id: publish_release_step
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
OUTPUT=$(gh release edit ${{ needs.create-release.outputs.draft_tag }} --draft=false)
|
||||||
|
echo "$OUTPUT"
|
||||||
|
RELEASE_TAG=$(echo "$OUTPUT" | sed -E 's|.*/releases/tag/||')
|
||||||
|
echo "release_tag=$RELEASE_TAG" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
notify-discord:
|
||||||
|
needs:
|
||||||
|
- publish-docker
|
||||||
|
- publish-helm
|
||||||
|
- create-release
|
||||||
|
- finalize-release
|
||||||
|
uses: ./.github/workflows/discord.yml
|
||||||
|
with:
|
||||||
|
release_tag: ${{ needs.create-release.outputs.version }}
|
||||||
|
discord_title: "New Release"
|
||||||
|
discord_color: 3066993
|
||||||
discord_footer: "Portabase"
|
discord_footer: "Portabase"
|
||||||
secrets:
|
secrets:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ on:
|
|||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sca-deps: # Dependency & container scan
|
sca-deps:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -17,15 +17,16 @@ jobs:
|
|||||||
ignore-unfixed: true
|
ignore-unfixed: true
|
||||||
|
|
||||||
|
|
||||||
secrets-gitleaks: # Secrets exposure
|
secrets-gitleaks:
|
||||||
|
if: github.event.pull_request.head.repo.fork == false
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Fetch full history for gitleaks
|
fetch-depth: 0
|
||||||
- uses: gitleaks/gitleaks-action@v2
|
- uses: gitleaks/gitleaks-action@v2
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
|
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
|
||||||
with:
|
with:
|
||||||
config-path: .gitleaks.toml # Optional, if you have a custom config
|
config-path: .gitleaks.toml
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"github": {
|
||||||
|
"release": true,
|
||||||
|
"draft": true,
|
||||||
|
"tokenRef": "GITHUB_TOKEN"
|
||||||
|
},
|
||||||
|
"git": {
|
||||||
|
"commit": true,
|
||||||
|
"commitMessage": "chore: release ${version}",
|
||||||
|
"requireCleanWorkingDir": true,
|
||||||
|
"tag": true,
|
||||||
|
"tagName": "${version}",
|
||||||
|
"push": true
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"@release-it/conventional-changelog": {
|
||||||
|
"preset": {
|
||||||
|
"name": "conventionalcommits",
|
||||||
|
"types": [
|
||||||
|
{
|
||||||
|
"type": "feat",
|
||||||
|
"section": "✨ Features"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "fix",
|
||||||
|
"section": "🐛 Bug Fixes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "perf",
|
||||||
|
"section": "⚡️ Performance Improvements"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "revert",
|
||||||
|
"section": "⏪️ Reverts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "docs",
|
||||||
|
"section": "📝 Documentation",
|
||||||
|
"hidden": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "chore",
|
||||||
|
"section": "🔧 Chores",
|
||||||
|
"hidden": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@release-it/bumper": {
|
||||||
|
"out": {
|
||||||
|
"file": "CITATION.cff",
|
||||||
|
"path": "version",
|
||||||
|
"type": "text/yaml"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-4
@@ -1,6 +1,6 @@
|
|||||||
cff-version: 1.2.0
|
cff-version: 1.2.0
|
||||||
title: Portabase
|
title: Portabase
|
||||||
message: "If you use this software, please cite it as below."
|
message: If you use this software, please cite it as below.
|
||||||
type: software
|
type: software
|
||||||
authors:
|
authors:
|
||||||
- family-names: Gauthereau
|
- family-names: Gauthereau
|
||||||
@@ -9,7 +9,12 @@ authors:
|
|||||||
given-names: Killian
|
given-names: Killian
|
||||||
repository-code: https://github.com/Portabase/portabase
|
repository-code: https://github.com/Portabase/portabase
|
||||||
url: https://portabase.io
|
url: https://portabase.io
|
||||||
abstract: "Portabase is a free, open-source, self-hosted solution for database administration, providing backup and restore capabilities, scheduling, retention policies, notifications, and support for multiple storage backends. Its headless agent architecture enables connection to multiple database instances securely and efficiently."
|
abstract: >-
|
||||||
|
Portabase is a free, open-source, self-hosted solution for database
|
||||||
|
administration, providing backup and restore capabilities, scheduling,
|
||||||
|
retention policies, notifications, and support for multiple storage backends.
|
||||||
|
Its headless agent architecture enables connection to multiple database
|
||||||
|
instances securely and efficiently.
|
||||||
keywords:
|
keywords:
|
||||||
- docker
|
- docker
|
||||||
- kubernetes
|
- kubernetes
|
||||||
@@ -26,5 +31,5 @@ keywords:
|
|||||||
- web-ui
|
- web-ui
|
||||||
- agent
|
- agent
|
||||||
license: Apache-2.0
|
license: Apache-2.0
|
||||||
version: 1.4.0
|
version: 1.5.3
|
||||||
date-released: "2026-02-27"
|
date-released: '2026-03-02'
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
[](https://www.postgresql.org/)
|
[](https://www.postgresql.org/)
|
||||||
[](https://www.mysql.com/)
|
[](https://www.mysql.com/)
|
||||||
[](https://mariadb.org/)
|
[](https://mariadb.org/)
|
||||||
|
[](https://sqlite.org/)
|
||||||
[](https://www.mongodb.com/)
|
[](https://www.mongodb.com/)
|
||||||
[](https://github.com/Portabase/portabase)
|
[](https://github.com/Portabase/portabase)
|
||||||
[](https://github.com/Portabase/portabase)
|
[](https://github.com/Portabase/portabase)
|
||||||
|
|||||||
-36
@@ -1,36 +0,0 @@
|
|||||||
import { PageParams } from "@/types/next";
|
|
||||||
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { DatabaseForm } from "@/components/wrappers/dashboard/database/database-form/database-form";
|
|
||||||
|
|
||||||
import { db } from "@/db";
|
|
||||||
import * as drizzleDb from "@/db";
|
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{ databaseId: string }>) {
|
|
||||||
const { databaseId } = await props.params;
|
|
||||||
|
|
||||||
const dbItem = await db.query.database.findFirst({
|
|
||||||
where: eq(drizzleDb.schemas.database.id, databaseId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!dbItem) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeader>
|
|
||||||
<PageTitle>Edit {dbItem.name}</PageTitle>
|
|
||||||
</PageHeader>
|
|
||||||
<PageContent>
|
|
||||||
<DatabaseForm
|
|
||||||
databaseId={databaseId}
|
|
||||||
// @ts-ignore
|
|
||||||
defaultValues={{ ...dbItem, dbms: dbItem.dbms ?? "inactive", description: dbItem.description ?? undefined }}
|
|
||||||
/>
|
|
||||||
</PageContent>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,89 +1,95 @@
|
|||||||
import {PageParams} from "@/types/next";
|
import { PageParams } from "@/types/next";
|
||||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
import {
|
||||||
import {currentUser} from "@/lib/auth/current-user";
|
Page,
|
||||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
PageActions,
|
||||||
import {notFound} from "next/navigation";
|
PageContent,
|
||||||
import {Metadata} from "next";
|
PageHeader,
|
||||||
import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
|
PageTitle,
|
||||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
} from "@/features/layout/page";
|
||||||
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
|
import { currentUser } from "@/lib/auth/current-user";
|
||||||
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
import { getActiveMember, getOrganization } from "@/lib/auth/auth";
|
||||||
import {DeleteOrganizationButton} from "@/components/wrappers/dashboard/organization/delete-organization-button";
|
import { notFound } from "next/navigation";
|
||||||
import {EditOrganizationDialog} from "@/features/organization/components/edit-organization.dialog";
|
import { Metadata } from "next";
|
||||||
import {Button} from "@/components/ui/button";
|
import { OrganizationTabs } from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
|
||||||
import {GearIcon} from "@radix-ui/react-icons";
|
import { getOrganizationChannels } from "@/db/services/notification-channel";
|
||||||
import {db} from "@/db";
|
import { computeOrganizationPermissions } from "@/lib/acl/organization-acl";
|
||||||
import {isNull} from "drizzle-orm";
|
import { getOrganizationStorageChannels } from "@/db/services/storage-channel";
|
||||||
|
import { DeleteOrganizationButton } from "@/components/wrappers/dashboard/organization/delete-organization-button";
|
||||||
|
import { EditOrganizationDialog } from "@/features/organization/components/edit-organization.dialog";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { isNull } from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {eq} from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||||
const organization = await getOrganization({});
|
const organization = await getOrganization({});
|
||||||
const user = await currentUser();
|
const user = await currentUser();
|
||||||
const activeMember = await getActiveMember()
|
const activeMember = await getActiveMember();
|
||||||
|
|
||||||
if (!organization || !activeMember || !user) {
|
if (!organization || !activeMember || !user) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const notificationChannels = await getOrganizationChannels(organization.id)
|
const notificationChannels = await getOrganizationChannels(organization.id);
|
||||||
const storageChannels = await getOrganizationStorageChannels(organization.id)
|
const storageChannels = await getOrganizationStorageChannels(organization.id);
|
||||||
const permissions = computeOrganizationPermissions(activeMember);
|
const permissions = computeOrganizationPermissions(activeMember);
|
||||||
|
|
||||||
const users = await db.query.user.findMany({
|
const users = await db.query.user.findMany({
|
||||||
where: (fields) => isNull(fields.deletedAt)
|
where: (fields) => isNull(fields.deletedAt),
|
||||||
});
|
});
|
||||||
|
|
||||||
const organizationWithMembers = await db.query.organization.findFirst({
|
const organizationWithMembers = await db.query.organization.findFirst({
|
||||||
where: eq(drizzleDb.schemas.organization.id, organization.id),
|
where: eq(drizzleDb.schemas.organization.id, organization.id),
|
||||||
|
with: {
|
||||||
|
members: {
|
||||||
with: {
|
with: {
|
||||||
members: {
|
user: true,
|
||||||
with: {
|
},
|
||||||
user: true
|
},
|
||||||
}
|
},
|
||||||
}
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!organizationWithMembers) notFound();
|
if (!organizationWithMembers) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||||
<div className="min-w-full md:min-w-fit ">
|
<div className="min-w-full md:min-w-fit ">Organization settings</div>
|
||||||
Organization settings
|
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
{permissions.canManageSettings &&
|
||||||
<div className="flex items-center gap-2">
|
organization.slug !== "default" && (
|
||||||
{permissions.canManageSettings && organization.slug !== "default" && (
|
<EditOrganizationDialog
|
||||||
<EditOrganizationDialog
|
organization={organizationWithMembers}
|
||||||
organization={organizationWithMembers}
|
users={users}
|
||||||
users={users}
|
currentUser={user}
|
||||||
currentUser={user}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
{permissions.canManageDangerZone &&
|
||||||
{permissions.canManageDangerZone && organization.slug !== "default" && (
|
organization.slug !== "default" && (
|
||||||
<DeleteOrganizationButton organizationSlug={organization.slug}/>
|
<DeleteOrganizationButton
|
||||||
)}
|
organizationSlug={organization.slug}
|
||||||
</div>
|
/>
|
||||||
</div>
|
)}
|
||||||
</PageTitle>
|
</div>
|
||||||
</PageHeader>
|
</div>
|
||||||
<PageContent>
|
</PageTitle>
|
||||||
<OrganizationTabs
|
</PageHeader>
|
||||||
activeMember={activeMember}
|
<PageContent>
|
||||||
organization={organization}
|
<OrganizationTabs
|
||||||
notificationChannels={notificationChannels}
|
activeMember={activeMember}
|
||||||
storageChannels={storageChannels}
|
organization={organization}
|
||||||
/>
|
notificationChannels={notificationChannels}
|
||||||
</PageContent>
|
storageChannels={storageChannels}
|
||||||
</Page>
|
/>
|
||||||
)
|
</PageContent>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
|||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
const body: BodyPatch = await request.json();
|
const body: BodyPatch = await request.json();
|
||||||
|
console.log("body", body);
|
||||||
|
|
||||||
const status = body.status
|
const status = body.status
|
||||||
const backupId = body.backupId
|
const backupId = body.backupId
|
||||||
@@ -131,7 +132,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
|||||||
|
|
||||||
|
|
||||||
eventEmitter.emit('modification', {update: true});
|
eventEmitter.emit('modification', {update: true});
|
||||||
await sendNotificationsBackupRestore(database, `${status}_backup` as EventKind);
|
await sendNotificationsBackupRestore(database, status == "failed" ? "error_backup" : "success_backup" as EventKind);
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -63,8 +63,7 @@ export async function POST(
|
|||||||
.set({status: body.status as RestorationStatus})
|
.set({status: body.status as RestorationStatus})
|
||||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||||
|
|
||||||
|
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
|
||||||
await sendNotificationsBackupRestore(database, body.status == "error" ? "error_restore" : "success_restore");
|
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
message: true,
|
message: true,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ services:
|
|||||||
# context: .
|
# context: .
|
||||||
# dockerfile: docker/dockerfile/Dockerfile
|
# dockerfile: docker/dockerfile/Dockerfile
|
||||||
# target: prod
|
# target: prod
|
||||||
image: portabase/portabase:1.2.7-rc.1
|
image: portabase/portabase:1.4.0
|
||||||
ports:
|
ports:
|
||||||
- '8887:80'
|
- '8887:80'
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
#FROM node:22-bullseye AS base
|
|
||||||
FROM --platform=$BUILDPLATFORM node:22-bullseye AS base
|
FROM --platform=$BUILDPLATFORM node:22-bullseye AS base
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
@@ -112,8 +111,6 @@ RUN chmod +x /usr/local/bin/tusd
|
|||||||
|
|
||||||
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
|
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
#RUN mkdir -p .next /app/private/uploads \
|
|
||||||
# && chown -R nextjs:nodejs .next /app/private /app/public
|
|
||||||
|
|
||||||
RUN mkdir -p .next /data/private/uploads \
|
RUN mkdir -p .next /data/private/uploads \
|
||||||
&& chown -R nextjs:nodejs .next /data/private /app/public
|
&& chown -R nextjs:nodejs .next /data/private /app/public
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
.DS_Store
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.bzr/
|
||||||
|
.bzrignore
|
||||||
|
.hg/
|
||||||
|
.hgignore
|
||||||
|
.svn/
|
||||||
|
*.swp
|
||||||
|
*.bak
|
||||||
|
*.tmp
|
||||||
|
*.orig
|
||||||
|
*~
|
||||||
|
.project
|
||||||
|
.idea/
|
||||||
|
*.tmproj
|
||||||
|
.vscode/
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: portabase
|
||||||
|
description: Helm chart for Portabase
|
||||||
|
type: application
|
||||||
|
version: 0.0.0
|
||||||
|
appVersion: "latest"
|
||||||
|
keywords:
|
||||||
|
- postgresql
|
||||||
|
- mariadb
|
||||||
|
- mongodb
|
||||||
|
- mysql
|
||||||
|
- sqlite
|
||||||
|
- backup
|
||||||
|
- database
|
||||||
|
- restore
|
||||||
|
home: https://github.com/Portabase/portabase
|
||||||
|
|
||||||
|
sources:
|
||||||
|
- https://github.com/Portabase/portabase
|
||||||
|
- https://github.com/Portabase/portabase/tree/main/helm
|
||||||
|
|
||||||
|
maintainers:
|
||||||
|
- name: Charles Gauthereau
|
||||||
|
url: https://github.com/RambokDev
|
||||||
|
- name: Killian Larcher
|
||||||
|
url: https://github.com/KillianLarcher
|
||||||
|
|
||||||
|
icon: https://raw.githubusercontent.com/Portabase/portabase/main/.github/assets/logo.png
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Development Notes
|
||||||
|
|
||||||
|
## Check that Kubernetes is reachable locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get nodes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install the local Portabase Helm chart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm install portabase . \
|
||||||
|
--set project.secret=$(openssl rand -hex 32)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Check the pods
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get pods
|
||||||
|
```
|
||||||
|
|
||||||
|
## Check the services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get svc
|
||||||
|
```
|
||||||
|
|
||||||
|
## If the service type is ClusterIP, expose it locally using port forwarding:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl port-forward svc/portabase 8887:80
|
||||||
|
```
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: portabase
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: portabase
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: portabase
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: portabase
|
||||||
|
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
||||||
|
env:
|
||||||
|
- name: TZ
|
||||||
|
value: {{ .Values.timezone }}
|
||||||
|
- name: PROJECT_URL
|
||||||
|
value: {{ .Values.project.url }}
|
||||||
|
- name: PROJECT_SECRET
|
||||||
|
value: {{ .Values.project.secret }}
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /data
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: portabase-data
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: portabase-data
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.persistence.size }}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: portabase
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.service.type }}
|
||||||
|
selector:
|
||||||
|
app: portabase
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.service.port }}
|
||||||
|
targetPort: 80
|
||||||
|
nodePort: {{ .Values.service.nodePort }}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
image:
|
||||||
|
repository: portabase/portabase
|
||||||
|
tag: latest
|
||||||
|
pullPolicy: Always
|
||||||
|
|
||||||
|
replicaCount: 1
|
||||||
|
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 80
|
||||||
|
targetPort: 80
|
||||||
|
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "1Gi"
|
||||||
|
cpu: "1"
|
||||||
|
limits:
|
||||||
|
memory: "1Gi"
|
||||||
|
cpu: "1"
|
||||||
|
|
||||||
|
timezone: Europe/Paris
|
||||||
|
|
||||||
|
project:
|
||||||
|
url: http://localhost:8887
|
||||||
|
secret: "change_me_generate_secure_secret"
|
||||||
|
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
storageClassName: ""
|
||||||
|
accessMode: ReadWriteOnce
|
||||||
|
size: 10Gi
|
||||||
|
mountPath: /data
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: false
|
||||||
|
className: nginx
|
||||||
|
hosts:
|
||||||
|
- host: portabase.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
tls: []
|
||||||
|
|
||||||
|
updateStrategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
rollingUpdate:
|
||||||
|
partition: 0
|
||||||
+6
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "portabase",
|
"name": "portabase",
|
||||||
"version": "1.4.0",
|
"version": "1.5.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 8887",
|
"dev": "next dev --turbopack -p 8887",
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:drop": "drizzle-kit drop",
|
"db:drop": "drizzle-kit drop",
|
||||||
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts"
|
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts",
|
||||||
|
"release": "release-it"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/passkey": "^1.4.19",
|
"@better-auth/passkey": "^1.4.19",
|
||||||
@@ -49,7 +50,6 @@
|
|||||||
"@t3-oss/env-nextjs": "^0.13.10",
|
"@t3-oss/env-nextjs": "^0.13.10",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/express": "^5.0.6",
|
|
||||||
"@types/nodemailer": "^6.4.23",
|
"@types/nodemailer": "^6.4.23",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@zenstackhq/runtime": "2.14.2",
|
"@zenstackhq/runtime": "2.14.2",
|
||||||
@@ -65,7 +65,6 @@
|
|||||||
"drizzle-orm": "^0.43.1",
|
"drizzle-orm": "^0.43.1",
|
||||||
"drizzle-zod": "^0.7.1",
|
"drizzle-zod": "^0.7.1",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"express": "^5.2.1",
|
|
||||||
"googleapis": "^170.1.0",
|
"googleapis": "^170.1.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
@@ -106,6 +105,8 @@
|
|||||||
"@iconify/react": "^6.0.2",
|
"@iconify/react": "^6.0.2",
|
||||||
"@react-email/preview-server": "4.3.2",
|
"@react-email/preview-server": "4.3.2",
|
||||||
"@react-email/render": "^2.0.4",
|
"@react-email/render": "^2.0.4",
|
||||||
|
"@release-it/bumper": "^7.0.5",
|
||||||
|
"@release-it/conventional-changelog": "^10.0.5",
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||||
"@types/node": "^22.19.11",
|
"@types/node": "^22.19.11",
|
||||||
@@ -123,6 +124,7 @@
|
|||||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
"eslint-plugin-tailwindcss": "^3.18.2",
|
||||||
"framer-motion": "^12.34.3",
|
"framer-motion": "^12.34.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
|
"release-it": "^19.2.4",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
|||||||
Generated
+1498
-343
File diff suppressed because it is too large
Load Diff
@@ -1,95 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if [ -z "$1" ]; then
|
|
||||||
echo "Usage: ./release <version>"
|
|
||||||
echo "Example: ./release 1.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!"
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import router from "../src/features/api/router";
|
|
||||||
|
|
||||||
export function mountApi(app: express.Express) {
|
|
||||||
app.use("/services/v1", router);
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import next from "next";
|
|
||||||
import express from "express";
|
|
||||||
import {mountApi} from "./api";
|
|
||||||
import http from "http";
|
|
||||||
|
|
||||||
const port = Number(process.env.PORT) || 8887;
|
|
||||||
const dev = process.env.NODE_ENV !== "production";
|
|
||||||
|
|
||||||
async function start() {
|
|
||||||
const nextApp = next({
|
|
||||||
dev,
|
|
||||||
hostname: "0.0.0.0",
|
|
||||||
turbopack: dev,
|
|
||||||
port
|
|
||||||
});
|
|
||||||
|
|
||||||
const handle = nextApp.getRequestHandler();
|
|
||||||
|
|
||||||
await nextApp.prepare();
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
req.setTimeout(0);
|
|
||||||
res.setTimeout(0);
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
mountApi(app);
|
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
if (req.path.startsWith('/services/v1')) return next();
|
|
||||||
handle(req, res).catch((err) => {
|
|
||||||
console.error('Next.js App Router error:', err);
|
|
||||||
res.status(500).send('Internal Server Error');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const server = http.createServer(app);
|
|
||||||
|
|
||||||
server.setTimeout(0);
|
|
||||||
server.headersTimeout = 0;
|
|
||||||
server.requestTimeout = 0;
|
|
||||||
server.keepAliveTimeout = 0;
|
|
||||||
|
|
||||||
server.listen(port, "0.0.0.0", () => {
|
|
||||||
console.log(`NEXT APP → http://localhost:${port}`);
|
|
||||||
console.log(`API → http://localhost:${port}/services/v1`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
start();
|
|
||||||
@@ -1,161 +1,198 @@
|
|||||||
"use client"
|
"use client";
|
||||||
import {Button, ButtonVariantsProps} from "@/components/ui/button";
|
import { Button, ButtonVariantsProps } from "@/components/ui/button";
|
||||||
import {ReactNode, useState} from "react";
|
import { ReactNode, useState } from "react";
|
||||||
import {Loader2} from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
|
import {
|
||||||
import {cn} from "@/lib/utils";
|
Popover,
|
||||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
export type ButtonWithConfirmProps = {
|
export type ButtonWithConfirmProps = {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
button?: {
|
button?: {
|
||||||
main: {
|
main: {
|
||||||
className?: string;
|
className?: string;
|
||||||
type?: "button" | "submit" | "reset" | undefined;
|
type?: "button" | "submit" | "reset" | undefined;
|
||||||
text?: string;
|
text?: string;
|
||||||
icon?: any;
|
icon?: any;
|
||||||
variant?: ButtonVariantsProps["variant"];
|
variant?: ButtonVariantsProps["variant"];
|
||||||
size?: ButtonVariantsProps["size"];
|
size?: ButtonVariantsProps["size"];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
tooltipText?: string;
|
tooltipText?: string;
|
||||||
};
|
|
||||||
confirm: {
|
|
||||||
className?: string;
|
|
||||||
text: string;
|
|
||||||
icon?: any;
|
|
||||||
variant?: ButtonVariantsProps["variant"];
|
|
||||||
size?: ButtonVariantsProps["size"];
|
|
||||||
onClick?: () => void;
|
|
||||||
};
|
|
||||||
cancel: {
|
|
||||||
className?: string;
|
|
||||||
text: string;
|
|
||||||
icon?: any;
|
|
||||||
variant?: ButtonVariantsProps["variant"];
|
|
||||||
size?: ButtonVariantsProps["size"];
|
|
||||||
onClick?: () => void;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
children?: ReactNode;
|
confirm: {
|
||||||
onConfirm?: (e: React.MouseEvent) => void;
|
className?: string;
|
||||||
onCancel?: (e: React.MouseEvent) => void;
|
text: string;
|
||||||
confirmButtonText?: string;
|
icon?: any;
|
||||||
cancelButtonText?: string;
|
variant?: ButtonVariantsProps["variant"];
|
||||||
isPending?: boolean;
|
size?: ButtonVariantsProps["size"];
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
cancel: {
|
||||||
|
className?: string;
|
||||||
|
text: string;
|
||||||
|
icon?: any;
|
||||||
|
variant?: ButtonVariantsProps["variant"];
|
||||||
|
size?: ButtonVariantsProps["size"];
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
children?: ReactNode;
|
||||||
|
onConfirm?: (e: React.MouseEvent) => void;
|
||||||
|
onCancel?: (e: React.MouseEvent) => void;
|
||||||
|
confirmButtonText?: string;
|
||||||
|
cancelButtonText?: string;
|
||||||
|
isPending?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
|
export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
|
||||||
const [isConfirming, setIsConfirming] = useState(false);
|
const [isConfirming, setIsConfirming] = useState(false);
|
||||||
|
|
||||||
const isLegacy = !!props.button;
|
const isLegacy = !!props.button;
|
||||||
const isDisabled = isLegacy ? !!props.button?.main.disabled : false;
|
const isDisabled = isLegacy ? !!props.button?.main.disabled : false;
|
||||||
|
|
||||||
const handleConfirm = (e: React.MouseEvent) => {
|
const handleConfirm = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isLegacy) {
|
||||||
|
props.button?.confirm.onClick?.();
|
||||||
|
} else {
|
||||||
|
props.onConfirm?.(e);
|
||||||
|
}
|
||||||
|
setIsConfirming(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isLegacy) {
|
||||||
|
props.button?.cancel.onClick?.();
|
||||||
|
} else {
|
||||||
|
props.onCancel?.(e);
|
||||||
|
}
|
||||||
|
setIsConfirming(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerContent = isLegacy ? (
|
||||||
|
<Button
|
||||||
|
type={props.button?.main.type}
|
||||||
|
disabled={isDisabled}
|
||||||
|
variant={props.button?.main.variant ?? "default"}
|
||||||
|
size={props.button?.main.size ?? "default"}
|
||||||
|
className={props.button?.main.className}
|
||||||
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (isLegacy) {
|
if (!isDisabled) setIsConfirming(true);
|
||||||
props.button?.confirm.onClick?.();
|
}}
|
||||||
} else {
|
>
|
||||||
props.onConfirm?.(e);
|
{props.isPending && <Loader2 className="animate-spin mr-4" size={16} />}
|
||||||
}
|
{props.button?.main.icon}
|
||||||
setIsConfirming(false);
|
{props.button?.main.text && <span>{props.button.main.text}</span>}
|
||||||
};
|
</Button>
|
||||||
|
) : (
|
||||||
const handleCancel = (e: React.MouseEvent) => {
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (isLegacy) {
|
setIsConfirming(true);
|
||||||
props.button?.cancel.onClick?.();
|
}}
|
||||||
} else {
|
>
|
||||||
props.onCancel?.(e);
|
{props.children}
|
||||||
}
|
</div>
|
||||||
setIsConfirming(false);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const triggerContent = isLegacy ? (
|
const withTooltip =
|
||||||
<Button
|
isLegacy && props.button?.main.tooltipText && isDisabled ? (
|
||||||
type={props.button?.main.type}
|
<TooltipProvider>
|
||||||
disabled={isDisabled}
|
<Tooltip>
|
||||||
variant={props.button?.main.variant ?? "default"}
|
<TooltipTrigger asChild>
|
||||||
size={props.button?.main.size ?? "default"}
|
<div>{triggerContent}</div>
|
||||||
className={props.button?.main.className}
|
</TooltipTrigger>
|
||||||
onClick={(e) => {
|
<TooltipContent>
|
||||||
e.preventDefault();
|
<p>{props.button?.main.tooltipText}</p>
|
||||||
e.stopPropagation();
|
</TooltipContent>
|
||||||
if (!isDisabled) setIsConfirming(true);
|
</Tooltip>
|
||||||
}}
|
</TooltipProvider>
|
||||||
>
|
|
||||||
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
|
|
||||||
{props.button?.main.icon}
|
|
||||||
{props.button?.main.text && <span>{props.button.main.text}</span>}
|
|
||||||
</Button>
|
|
||||||
) : (
|
) : (
|
||||||
<div onClick={(e) => {
|
triggerContent
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsConfirming(true);
|
|
||||||
}}>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const withTooltip = isLegacy && props.button?.main.tooltipText && isDisabled ? (
|
return (
|
||||||
<TooltipProvider>
|
<Popover open={isConfirming} onOpenChange={setIsConfirming}>
|
||||||
<Tooltip>
|
<PopoverTrigger asChild>{withTooltip}</PopoverTrigger>
|
||||||
<TooltipTrigger asChild>
|
<PopoverContent
|
||||||
<div>{triggerContent}</div>
|
className="w-80"
|
||||||
</TooltipTrigger>
|
onPointerDownOutside={(e) => e.preventDefault()}
|
||||||
<TooltipContent>
|
onInteractOutside={(e) => e.preventDefault()}
|
||||||
<p>{props.button?.main.tooltipText}</p>
|
onClick={(e) => e.stopPropagation()}
|
||||||
</TooltipContent>
|
onMouseMove={(e) => e.stopPropagation()}
|
||||||
</Tooltip>
|
onMouseEnter={(e) => e.stopPropagation()}
|
||||||
</TooltipProvider>
|
onMouseLeave={(e) => e.stopPropagation()}
|
||||||
) : triggerContent;
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
return (
|
<div className="grid gap-4">
|
||||||
<Popover open={isConfirming} onOpenChange={setIsConfirming}>
|
<div className="space-y-2">
|
||||||
<PopoverTrigger asChild>
|
<h4 className="font-medium leading-none">{props.title}</h4>
|
||||||
{withTooltip}
|
<p className="text-sm text-muted-foreground">{props.description}</p>
|
||||||
</PopoverTrigger>
|
</div>
|
||||||
<PopoverContent
|
<div className="grid gap-2">
|
||||||
className="w-80"
|
<Button
|
||||||
onPointerDownOutside={(e) => e.preventDefault()}
|
onClick={handleConfirm}
|
||||||
onInteractOutside={(e) => e.preventDefault()}
|
variant={
|
||||||
onClick={(e) => e.stopPropagation()}
|
isLegacy
|
||||||
onMouseMove={(e) => e.stopPropagation()}
|
? (props.button?.confirm.variant ?? "default")
|
||||||
onMouseEnter={(e) => e.stopPropagation()}
|
: "default"
|
||||||
onMouseLeave={(e) => e.stopPropagation()}
|
}
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
size={
|
||||||
|
isLegacy ? (props.button?.confirm.size ?? "default") : "default"
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
isLegacy ? props.button?.confirm.className : "",
|
||||||
|
"w-full",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="grid gap-4">
|
{props.isPending && (
|
||||||
<div className="space-y-2">
|
<Loader2 className="animate-spin mr-4" size={16} />
|
||||||
<h4 className="font-medium leading-none">{props.title}</h4>
|
)}
|
||||||
<p className="text-sm text-muted-foreground">{props.description}</p>
|
{isLegacy && props.button?.confirm.icon}
|
||||||
</div>
|
<span>
|
||||||
<div className="grid gap-2">
|
{isLegacy
|
||||||
<Button
|
? props.button?.confirm.text
|
||||||
onClick={handleConfirm}
|
: (props.confirmButtonText ?? "Confirm")}
|
||||||
variant={isLegacy ? (props.button?.confirm.variant ?? "default") : "default"}
|
</span>
|
||||||
size={isLegacy ? (props.button?.main.size ?? "default") : "default"}
|
</Button>
|
||||||
className={cn(isLegacy ? props.button?.main.className : "", "w-full")}
|
<Button
|
||||||
>
|
variant={
|
||||||
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
|
isLegacy
|
||||||
{isLegacy && props.button?.confirm.icon}
|
? (props.button?.cancel.variant ?? "outline")
|
||||||
<span>{isLegacy ? props.button?.confirm.text : (props.confirmButtonText ?? "Confirm")}</span>
|
: "outline"
|
||||||
</Button>
|
}
|
||||||
<Button
|
onClick={handleCancel}
|
||||||
variant={isLegacy ? (props.button?.cancel.variant ?? "outline") : "outline"}
|
className={cn(
|
||||||
onClick={handleCancel}
|
isLegacy ? props.button?.cancel.className : "",
|
||||||
className={cn(isLegacy ? props.button?.main.className : "", "w-full")}
|
"w-full",
|
||||||
size={isLegacy ? (props.button?.main.size ?? "default") : "default"}
|
)}
|
||||||
>
|
size={
|
||||||
{isLegacy ? (props.button?.cancel.text ?? "Cancel") : (props.cancelButtonText ?? "Cancel")}
|
isLegacy ? (props.button?.cancel.size ?? "default") : "default"
|
||||||
</Button>
|
}
|
||||||
</div>
|
>
|
||||||
</div>
|
{isLegacy
|
||||||
</PopoverContent>
|
? (props.button?.cancel.text ?? "Cancel")
|
||||||
</Popover>
|
: (props.cancelButtonText ?? "Cancel")}
|
||||||
);
|
</Button>
|
||||||
};
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
+55
-12
@@ -1,26 +1,69 @@
|
|||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
|
import {SlackChannelConfigSchema} from "./providers/notifications/forms/slack.schema";
|
||||||
|
import {SmtpChannelConfigSchema} from "./providers/notifications/forms/smtp.schema";
|
||||||
|
import {DiscordChannelConfigSchema} from "./providers/notifications/forms/discord.schema";
|
||||||
|
import {TelegramChannelConfigSchema} from "./providers/notifications/forms/telegram.schema";
|
||||||
|
import {GotifyChannelConfigSchema} from "./providers/notifications/forms/gotify.schema";
|
||||||
|
import {NtfyChannelConfigSchema} from "./providers/notifications/forms/ntfy.schema";
|
||||||
|
import {WebhookChannelConfigSchema} from "./providers/notifications/forms/webhook.schema";
|
||||||
|
import {S3ChannelConfigSchema} from "./providers/storages/forms/s3.schema";
|
||||||
|
import {GoogleDriveChannelConfigSchema} from "./providers/storages/forms/google-drive.schema";
|
||||||
|
import {LocalChannelConfigSchema} from "./providers/storages/forms/local.schema";
|
||||||
|
|
||||||
|
|
||||||
export const ChannelFormSchema = z.object({
|
const BaseChannelFormSchema = z.object({
|
||||||
name: z
|
name: z
|
||||||
.string()
|
.string()
|
||||||
.min(5, "Name must be at least 5 characters long")
|
.min(5, "Name must be at least 5 characters long")
|
||||||
.max(40, "Name must be at most 40 characters long"),
|
.max(40, "Name must be at most 40 characters long"),
|
||||||
config: z.record(z.union([z.string(), z.number(), z.boolean(), z.null(), z.undefined()])).optional(),
|
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const NotificationChannelFormSchema = z.discriminatedUnion("provider", [
|
||||||
|
BaseChannelFormSchema.extend({
|
||||||
|
provider: z.literal("slack"),
|
||||||
|
config: SlackChannelConfigSchema,
|
||||||
|
}),
|
||||||
|
BaseChannelFormSchema.extend({
|
||||||
|
provider: z.literal("smtp"),
|
||||||
|
config: SmtpChannelConfigSchema,
|
||||||
|
}),
|
||||||
|
BaseChannelFormSchema.extend({
|
||||||
|
provider: z.literal("discord"),
|
||||||
|
config: DiscordChannelConfigSchema,
|
||||||
|
}),
|
||||||
|
BaseChannelFormSchema.extend({
|
||||||
|
provider: z.literal("telegram"),
|
||||||
|
config: TelegramChannelConfigSchema,
|
||||||
|
}),
|
||||||
|
BaseChannelFormSchema.extend({
|
||||||
|
provider: z.literal("gotify"),
|
||||||
|
config: GotifyChannelConfigSchema,
|
||||||
|
}),
|
||||||
|
BaseChannelFormSchema.extend({
|
||||||
|
provider: z.literal("ntfy"),
|
||||||
|
config: NtfyChannelConfigSchema,
|
||||||
|
}),
|
||||||
|
BaseChannelFormSchema.extend({
|
||||||
|
provider: z.literal("webhook"),
|
||||||
|
config: WebhookChannelConfigSchema,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
export const NotificationChannelFormSchema = ChannelFormSchema.extend({
|
export const StorageChannelFormSchema = z.discriminatedUnion("provider", [
|
||||||
provider: z.enum(
|
BaseChannelFormSchema.extend({
|
||||||
["slack", "smtp", "discord", "telegram", "gotify", "ntfy", "webhook"],
|
provider: z.literal("s3"),
|
||||||
{required_error: "Provider is required"}
|
config: S3ChannelConfigSchema,
|
||||||
),
|
}),
|
||||||
});
|
BaseChannelFormSchema.extend({
|
||||||
|
provider: z.literal("google-drive"),
|
||||||
export const StorageChannelFormSchema = ChannelFormSchema.extend({
|
config: GoogleDriveChannelConfigSchema,
|
||||||
provider: z.enum(["local", "s3", "google-drive"], {required_error: "Provider is required"}),
|
}),
|
||||||
});
|
BaseChannelFormSchema.extend({
|
||||||
|
provider: z.literal("local"),
|
||||||
|
config: LocalChannelConfigSchema
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
export type NotificationChannelFormType = z.infer<typeof NotificationChannelFormSchema>;
|
export type NotificationChannelFormType = z.infer<typeof NotificationChannelFormSchema>;
|
||||||
|
|||||||
+35
-27
@@ -1,30 +1,38 @@
|
|||||||
import {UseFormReturn} from "react-hook-form";
|
import { UseFormReturn } from "react-hook-form";
|
||||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
import {
|
||||||
import {Input} from "@/components/ui/input";
|
FormControl,
|
||||||
import {Separator} from "@/components/ui/separator";
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { PasswordInput } from "@/components/ui/password-input";
|
||||||
|
|
||||||
type NotifierDiscordFormProps = {
|
type NotifierDiscordFormProps = {
|
||||||
form: UseFormReturn<any, any, any>
|
form: UseFormReturn<any, any, any>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const NotifierDiscordForm = ({form}: NotifierDiscordFormProps) => {
|
export const NotifierDiscordForm = ({ form }: NotifierDiscordFormProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Separator className="my-1"/>
|
<Separator className="my-1" />
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.discordWebhook"
|
name="config.discordWebhook"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Discord Webhook URL</FormLabel>
|
<FormLabel>Discord Webhook URL</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="https://discord.com/api/webhooks/..."/>
|
<PasswordInput
|
||||||
</FormControl>
|
{...field}
|
||||||
<FormMessage/>
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
</FormItem>
|
/>
|
||||||
)}
|
</FormControl>
|
||||||
/>
|
<FormMessage />
|
||||||
</>
|
</FormItem>
|
||||||
)
|
)}
|
||||||
}
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const DiscordChannelConfigSchema = z.object({
|
||||||
|
discordWebhook: z.string().url("Must be a valid URL"),
|
||||||
|
});
|
||||||
+46
-40
@@ -1,43 +1,49 @@
|
|||||||
import {UseFormReturn} from "react-hook-form";
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
import {
|
||||||
import {Input} from "@/components/ui/input";
|
FormControl,
|
||||||
import {Separator} from "@/components/ui/separator";
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { PasswordInput } from "@/components/ui/password-input";
|
||||||
|
|
||||||
type NotifierGotifyFormProps = {
|
type NotifierGotifyFormProps = {
|
||||||
form: UseFormReturn<any, any, any>
|
form: UseFormReturn<any, any, any>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const NotifierGotifyForm = ({form}: NotifierGotifyFormProps) => {
|
export const NotifierGotifyForm = ({ form }: NotifierGotifyFormProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Separator className="my-1"/>
|
<Separator className="my-1" />
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.gotifyServerUrl"
|
name="config.gotifyServerUrl"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Gotify Server URL</FormLabel>
|
<FormLabel>Gotify Server URL</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="https://gotify.example.com"/>
|
<Input {...field} placeholder="https://gotify.example.com" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.gotifyAppToken"
|
name="config.gotifyAppToken"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Application Token</FormLabel>
|
<FormLabel>Application Token</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="A1b2C3d4E5f6G7h"/>
|
<PasswordInput {...field} placeholder="A1b2C3d4E5f6G7h" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const GotifyChannelConfigSchema = z.object({
|
||||||
|
gotifyServerUrl: z.string().url("Must be a valid URL"),
|
||||||
|
gotifyAppToken: z.string().min(1, "App token is required"),
|
||||||
|
});
|
||||||
+3
-2
@@ -2,6 +2,7 @@ import {UseFormReturn} from "react-hook-form";
|
|||||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||||
import {Input} from "@/components/ui/input";
|
import {Input} from "@/components/ui/input";
|
||||||
import {Separator} from "@/components/ui/separator";
|
import {Separator} from "@/components/ui/separator";
|
||||||
|
import {PasswordInput} from "@/components/ui/password-input";
|
||||||
|
|
||||||
type NotifierNtfyFormProps = {
|
type NotifierNtfyFormProps = {
|
||||||
form: UseFormReturn<any, any, any>
|
form: UseFormReturn<any, any, any>
|
||||||
@@ -48,7 +49,7 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Access Token (Optional)</FormLabel>
|
<FormLabel>Access Token (Optional)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="tk_..."/>
|
<PasswordInput {...field} placeholder="tk_..."/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<p className="text-xs text-muted-foreground">Only required for protected topics or self-hosted instances with auth.</p>
|
<p className="text-xs text-muted-foreground">Only required for protected topics or self-hosted instances with auth.</p>
|
||||||
<FormMessage/>
|
<FormMessage/>
|
||||||
@@ -79,7 +80,7 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Basic Auth Password (Optional)</FormLabel>
|
<FormLabel>Basic Auth Password (Optional)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} type="password" placeholder="password"/>
|
<PasswordInput placeholder="password" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const NtfyChannelConfigSchema = z.object({
|
||||||
|
ntfyTopic: z.string().min(1, "Topic is required"),
|
||||||
|
ntfyServerUrl: z.string().url("Must be a valid URL").optional().or(z.literal('')),
|
||||||
|
ntfyToken: z.string().optional(),
|
||||||
|
ntfyUsername: z.string().optional(),
|
||||||
|
ntfyPassword: z.string().optional(),
|
||||||
|
});
|
||||||
+35
-28
@@ -1,31 +1,38 @@
|
|||||||
|
import { UseFormReturn } from "react-hook-form";
|
||||||
import {UseFormReturn} from "react-hook-form";
|
import {
|
||||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
FormControl,
|
||||||
import {Input} from "@/components/ui/input";
|
FormField,
|
||||||
import {Separator} from "@/components/ui/separator";
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { PasswordInput } from "@/components/ui/password-input";
|
||||||
|
|
||||||
type NotifierSmtpFormProps = {
|
type NotifierSmtpFormProps = {
|
||||||
form: UseFormReturn<any, any, any>
|
form: UseFormReturn<any, any, any>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const NotifierSlackForm = ({form}: NotifierSmtpFormProps) => {
|
export const NotifierSlackForm = ({ form }: NotifierSmtpFormProps) => {
|
||||||
return(
|
return (
|
||||||
<>
|
<>
|
||||||
<Separator className="my-1"/>
|
<Separator className="my-1" />
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.slackWebhook"
|
name="config.slackWebhook"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Slack Webhook URL</FormLabel>
|
<FormLabel>Slack Webhook URL</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="https://hooks.slack.com/services/..."/>
|
<PasswordInput
|
||||||
</FormControl>
|
{...field}
|
||||||
<FormMessage/>
|
placeholder="https://hooks.slack.com/services/..."
|
||||||
</FormItem>
|
/>
|
||||||
)}
|
</FormControl>
|
||||||
/>
|
<FormMessage />
|
||||||
</>
|
</FormItem>
|
||||||
)
|
)}
|
||||||
}
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const SlackChannelConfigSchema = z.object({
|
||||||
|
slackWebhook: z.string().url("Must be a valid URL"),
|
||||||
|
});
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const SmtpChannelConfigSchema = z.object({
|
||||||
|
host: z.string().min(1, "Host is required"),
|
||||||
|
port: z.coerce.number().min(1, "Port is required"),
|
||||||
|
user: z.string().min(1, "User is required"),
|
||||||
|
password: z.string().min(1, "Password is required"),
|
||||||
|
from: z.string().min(1, "From is required"),
|
||||||
|
to: z.string().min(1, "To is required"),
|
||||||
|
});
|
||||||
+81
-45
@@ -1,48 +1,84 @@
|
|||||||
import {UseFormReturn} from "react-hook-form";
|
import { UseFormReturn } from "react-hook-form";
|
||||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
import {
|
||||||
import {Input} from "@/components/ui/input";
|
FormControl,
|
||||||
import {Separator} from "@/components/ui/separator";
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { PasswordInput } from "@/components/ui/password-input";
|
||||||
|
|
||||||
type NotifierTelegramFormProps = {
|
type NotifierTelegramFormProps = {
|
||||||
form: UseFormReturn<any, any, any>
|
form: UseFormReturn<any, any, any>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const NotifierTelegramForm = ({form}: NotifierTelegramFormProps) => {
|
export const NotifierTelegramForm = ({ form }: NotifierTelegramFormProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Separator className="my-1"/>
|
<Separator className="my-1" />
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.telegramBotToken"
|
name="config.telegramBotToken"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Telegram Bot Token</FormLabel>
|
<FormLabel>Telegram Bot Token</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"/>
|
<PasswordInput
|
||||||
</FormControl>
|
{...field}
|
||||||
<FormMessage/>
|
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||||
</FormItem>
|
/>
|
||||||
)}
|
</FormControl>
|
||||||
/>
|
<FormMessage />
|
||||||
<FormField
|
</FormItem>
|
||||||
control={form.control}
|
)}
|
||||||
name="config.telegramChatId"
|
/>
|
||||||
render={({field}) => (
|
<FormField
|
||||||
<FormItem>
|
control={form.control}
|
||||||
<FormLabel>Telegram Chat ID</FormLabel>
|
name="config.telegramChatId"
|
||||||
<FormControl>
|
render={({ field }) => (
|
||||||
<Input {...field} placeholder="-100123456789 or 123456789"/>
|
<FormItem>
|
||||||
</FormControl>
|
<FormLabel>Telegram Chat ID</FormLabel>
|
||||||
<p className="text-xs text-muted-foreground">
|
<FormControl>
|
||||||
You must start the conversation with the bot first (<strong>/start</strong>). <br/>
|
<Input {...field} placeholder="-100123456789 or 123456789" />
|
||||||
For groups, add the bot to the group. <br/>
|
</FormControl>
|
||||||
You can use <a href="https://t.me/userinfobot" target="_blank" rel="noopener noreferrer" className="underline">@userinfobot</a> to find your ID.
|
<p className="text-xs text-muted-foreground">
|
||||||
</p>
|
You must start the conversation with the bot first (
|
||||||
<FormMessage/>
|
<strong>/start</strong>). <br />
|
||||||
</FormItem>
|
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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="config.telegramTopicId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Telegram Topic ID</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="123456" />
|
||||||
|
</FormControl>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Unique identifier for the target message thread (topic) of the
|
||||||
|
forum; for forum supergroups only
|
||||||
|
</p>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const TelegramChannelConfigSchema = z
|
||||||
|
.object({
|
||||||
|
telegramBotToken: z.string().min(1, "Bot token is required"),
|
||||||
|
telegramChatId: z.string().min(1, "Chat ID is required"),
|
||||||
|
telegramTopicId: z.string().optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.telegramTopicId && !data.telegramChatId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Chat ID is required when a Topic ID is provided",
|
||||||
|
path: ["telegramChatId"],
|
||||||
|
},
|
||||||
|
);
|
||||||
+69
-62
@@ -1,65 +1,72 @@
|
|||||||
import {UseFormReturn} from "react-hook-form";
|
import { UseFormReturn } from "react-hook-form";
|
||||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
import {
|
||||||
import {Input} from "@/components/ui/input";
|
FormControl,
|
||||||
import {Separator} from "@/components/ui/separator";
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { PasswordInput } from "@/components/ui/password-input";
|
||||||
|
|
||||||
type NotifierWebhookFormProps = {
|
type NotifierWebhookFormProps = {
|
||||||
form: UseFormReturn<any, any, any>
|
form: UseFormReturn<any, any, any>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const NotifierWebhookForm = ({form}: NotifierWebhookFormProps) => {
|
export const NotifierWebhookForm = ({ form }: NotifierWebhookFormProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Separator className="my-1"/>
|
<Separator className="my-1" />
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.webhookUrl"
|
name="config.webhookUrl"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Webhook URL</FormLabel>
|
<FormLabel>Webhook URL</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="https://example.com/api/webhook"/>
|
<Input {...field} placeholder="https://example.com/api/webhook" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.webhookSecretHeader"
|
name="config.webhookSecretHeader"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Header Name (Optional)</FormLabel>
|
<FormLabel>Header Name (Optional)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="X-Webhook-Secret"/>
|
<Input {...field} placeholder="X-Webhook-Secret" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.webhookSecret"
|
name="config.webhookSecret"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Secret Value (Optional)</FormLabel>
|
<FormLabel>Secret Value (Optional)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="Secret value..."/>
|
<PasswordInput {...field} placeholder="Secret value..." />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<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>).
|
If provided, the secret will be sent in the specified header (defaults
|
||||||
</p>
|
to <code>X-Webhook-Secret</code>).
|
||||||
</>
|
</p>
|
||||||
)
|
</>
|
||||||
}
|
);
|
||||||
|
};
|
||||||
|
|||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const WebhookChannelConfigSchema = z.object({
|
||||||
|
webhookUrl: z.string().url("Must be a valid URL"),
|
||||||
|
webhookSecretHeader: z.string().optional(),
|
||||||
|
webhookSecret: z.string().optional(),
|
||||||
|
});
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const GoogleDriveChannelConfigSchema = z.object({
|
||||||
|
clientId: z.string().min(1, "Client ID is required"),
|
||||||
|
clientSecret: z.string().min(1, "Client Secret is required"),
|
||||||
|
folderId: z.string().min(1, "Folder ID is required"),
|
||||||
|
refreshToken: z.string().optional(),
|
||||||
|
});
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const LocalChannelConfigSchema = z.object({});
|
||||||
+112
-111
@@ -1,115 +1,116 @@
|
|||||||
import {UseFormReturn} from "react-hook-form";
|
import { UseFormReturn } from "react-hook-form";
|
||||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
import {
|
||||||
import {Input} from "@/components/ui/input";
|
FormControl,
|
||||||
import {Separator} from "@/components/ui/separator";
|
FormField,
|
||||||
import {PasswordInput} from "@/components/ui/password-input";
|
FormItem,
|
||||||
import {Switch} from "@/components/ui/switch";
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { PasswordInput } from "@/components/ui/password-input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
||||||
type StorageS3FormProps = {
|
type StorageS3FormProps = {
|
||||||
form: UseFormReturn<any, any, any>
|
form: UseFormReturn<any, any, any>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const StorageS3Form = ({form}: StorageS3FormProps) => {
|
export const StorageS3Form = ({ form }: StorageS3FormProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Separator className="my-1"/>
|
<Separator className="my-1" />
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.endPointUrl"
|
name="config.endPointUrl"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Endpoint URL</FormLabel>
|
<FormLabel>Endpoint URL</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="s3.amazonaws.com"/>
|
<Input {...field} placeholder="s3.amazonaws.com" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.region"
|
name="config.region"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Region</FormLabel>
|
<FormLabel>Region</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="us-east-1"/>
|
<Input {...field} placeholder="us-east-1" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.accessKey"
|
name="config.accessKey"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Access Key</FormLabel>
|
<FormLabel>Access Key</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="A1b2C3d4E5f6G7h"/>
|
<Input {...field} placeholder="A1b2C3d4E5f6G7h" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.secretKey"
|
name="config.secretKey"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Secret Key</FormLabel>
|
<FormLabel>Secret Key</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<PasswordInput {...field} placeholder="A1b2C3d4E5f6G7h"/>
|
<PasswordInput {...field} placeholder="A1b2C3d4E5f6G7h" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.bucketName"
|
name="config.bucketName"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Bucket name</FormLabel>
|
<FormLabel>Bucket name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="portabase-dev"/>
|
<Input {...field} placeholder="portabase-dev" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.port"
|
name="config.port"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Port</FormLabel>
|
<FormLabel>Port</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} type="number" placeholder="443" />
|
<Input {...field} type="number" placeholder="443" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.ssl"
|
name="config.ssl"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Use SSL</FormLabel>
|
<FormLabel>Use SSL</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Switch
|
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||||
checked={field.value}
|
</FormControl>
|
||||||
onCheckedChange={field.onChange}
|
<FormMessage />
|
||||||
/>
|
</FormItem>
|
||||||
</FormControl>
|
)}
|
||||||
<FormMessage />
|
/>
|
||||||
</FormItem>
|
</>
|
||||||
)}
|
);
|
||||||
/>
|
};
|
||||||
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const S3ChannelConfigSchema = z.object({
|
||||||
|
endPointUrl: z.string().min(1, "Endpoint URL is required"),
|
||||||
|
region: z.string().optional(),
|
||||||
|
accessKey: z.string().min(1, "Access Key is required"),
|
||||||
|
secretKey: z.string().min(1, "Secret Key is required"),
|
||||||
|
bucketName: z.string().min(1, "Bucket name is required"),
|
||||||
|
port: z.coerce.number().optional(),
|
||||||
|
ssl: z.boolean().optional().default(true),
|
||||||
|
});
|
||||||
@@ -1,60 +1,65 @@
|
|||||||
"use client"
|
"use client";
|
||||||
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
import { NotificationChannelWith } from "@/db/schema/09_notification-channel";
|
||||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
import { CardsWithPagination } from "@/components/wrappers/common/cards-with-pagination";
|
||||||
import {useState} from "react";
|
import { useState } from "react";
|
||||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
import { EmptyStatePlaceholder } from "@/components/wrappers/common/empty-state-placeholder";
|
||||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
import { OrganizationWithMembers } from "@/db/schema/03_organization";
|
||||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
import { StorageChannelWith } from "@/db/schema/12_storage-channel";
|
||||||
import {ChannelCard} from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card";
|
import { ChannelCard } from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card";
|
||||||
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
|
import { ChannelAddEditModal } from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
|
||||||
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
import {
|
||||||
|
ChannelKind,
|
||||||
|
getChannelTextBasedOnKind,
|
||||||
|
} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||||
|
|
||||||
type ChannelsSectionProps = {
|
type ChannelsSectionProps = {
|
||||||
channels: NotificationChannelWith[] | StorageChannelWith[],
|
channels: NotificationChannelWith[] | StorageChannelWith[];
|
||||||
organizations: OrganizationWithMembers[],
|
organizations: OrganizationWithMembers[];
|
||||||
kind: ChannelKind,
|
kind: ChannelKind;
|
||||||
defaultStorageChannelId?: string | null | undefined
|
defaultStorageChannelId?: string | null | undefined;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const ChannelsSection = ({
|
export const ChannelsSection = ({
|
||||||
organizations,
|
organizations,
|
||||||
channels,
|
channels,
|
||||||
kind,
|
kind,
|
||||||
defaultStorageChannelId
|
defaultStorageChannelId,
|
||||||
}: ChannelsSectionProps) => {
|
}: ChannelsSectionProps) => {
|
||||||
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||||
|
const channelText = getChannelTextBasedOnKind(kind);
|
||||||
|
const hasChannels = channels.length > 0;
|
||||||
|
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
return (
|
||||||
const channelText = getChannelTextBasedOnKind(kind)
|
<div className="h-full">
|
||||||
const hasChannels = channels.length > 0
|
<ChannelAddEditModal
|
||||||
|
kind={kind}
|
||||||
|
open={isAddModalOpen}
|
||||||
return (
|
onOpenChangeAction={setIsAddModalOpen}
|
||||||
|
adminView={false}
|
||||||
|
trigger={false}
|
||||||
|
/>
|
||||||
|
{hasChannels ? (
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
<ChannelAddEditModal kind={kind} open={isAddModalOpen} onOpenChangeAction={setIsAddModalOpen}
|
<CardsWithPagination
|
||||||
adminView={false}
|
data={channels}
|
||||||
trigger={false}/>
|
cardItem={ChannelCard}
|
||||||
{hasChannels ? (
|
cardsPerPage={8}
|
||||||
<div className="h-full">
|
numberOfColumns={2}
|
||||||
<CardsWithPagination
|
adminView={true}
|
||||||
data={channels}
|
organizations={organizations}
|
||||||
cardItem={ChannelCard}
|
kind={kind}
|
||||||
cardsPerPage={8}
|
defaultStorageChannelId={defaultStorageChannelId}
|
||||||
numberOfColumns={2}
|
/>
|
||||||
adminView={true}
|
|
||||||
organizations={organizations}
|
|
||||||
kind={kind}
|
|
||||||
defaultStorageChannelId={defaultStorageChannelId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyStatePlaceholder
|
|
||||||
text={`No ${channelText} channels configured yet`}
|
|
||||||
onClick={() => {
|
|
||||||
setIsAddModalOpen(true)
|
|
||||||
}}
|
|
||||||
className="h-full"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
) : (
|
||||||
}
|
<EmptyStatePlaceholder
|
||||||
|
text={`No ${channelText} channels configured yet`}
|
||||||
|
onClick={() => {
|
||||||
|
setIsAddModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="h-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
+232
-53
@@ -1,62 +1,241 @@
|
|||||||
"use client"
|
"use client";
|
||||||
import {Eye} from "lucide-react";
|
|
||||||
|
|
||||||
import {useState} from "react";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
AlertCircle,
|
||||||
DialogContent,
|
Braces,
|
||||||
DialogDescription,
|
Database,
|
||||||
DialogHeader,
|
ExternalLink,
|
||||||
DialogTitle,
|
Eye,
|
||||||
DialogTrigger
|
Hash,
|
||||||
|
Server,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {Button} from "@/components/ui/button";
|
import type { NotificationLogWithRelations } from "@/db/services/notification-log";
|
||||||
import {Separator} from "@/components/ui/separator";
|
|
||||||
import {NotificationLogWithRelations} from "@/db/services/notification-log";
|
|
||||||
|
|
||||||
type NotificationLogModalProps = {
|
type NotificationLogModalProps = {
|
||||||
notificationLog: NotificationLogWithRelations;
|
notificationLog: NotificationLogWithRelations;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const NotificationLogModal = ({notificationLog}: NotificationLogModalProps) => {
|
const getTroubleshootingForError = (errorMsg?: any) => {
|
||||||
const [open, setOpen] = useState(false);
|
if (!errorMsg || String(errorMsg).toLowerCase() === "null") return null;
|
||||||
|
|
||||||
return (
|
const msg = String(errorMsg).toLowerCase();
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="outline" onClick={() => setOpen(true)} className="relative">
|
|
||||||
<Eye/>
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Notification details</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Details of the notification sent
|
|
||||||
</DialogDescription>
|
|
||||||
<Separator className="mt-3 mb-3"/>
|
|
||||||
|
|
||||||
<div className="space-y-2 mb-4">
|
if (msg.includes("database connection") || msg.includes("agent")) {
|
||||||
<div>
|
return {
|
||||||
<span className="font-semibold">Title:</span>{" "}
|
title: "Agent Connection Issue",
|
||||||
<span>{notificationLog.content.title}</span>
|
resolution:
|
||||||
</div>
|
"It appears the agent cannot reach the database. Please ensure that your agent is actively running, your PostgreSQL credentials are correct, and port 5432 is open on your firewall.",
|
||||||
<div>
|
docLink:
|
||||||
<span className="font-semibold">Message:</span>{" "}
|
"https://portabase.io/docs/agent/troubleshooting/agent-connection",
|
||||||
<span>{notificationLog.content.message}</span>
|
};
|
||||||
</div>
|
}
|
||||||
|
|
||||||
|
if (msg.includes("timeout")) {
|
||||||
|
return {
|
||||||
|
title: "Timeout",
|
||||||
|
resolution:
|
||||||
|
"The agent is taking too long to respond. This could be due to high server load, network issues, or a large database.",
|
||||||
|
docLink: "https://portabase.io/docs/agent/troubleshooting/timeout",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: "Unexpected Execution Error",
|
||||||
|
resolution:
|
||||||
|
"An unexpected error occurred during execution. Please review the raw system logs or contact support if the issue persists.",
|
||||||
|
docLink: "https://discord.gg/Wgv7xZ8fWJ",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIconForKey = (key: string, value: any) => {
|
||||||
|
const isActualError =
|
||||||
|
key.toLowerCase().includes("error") &&
|
||||||
|
value !== null &&
|
||||||
|
String(value).toLowerCase() !== "null";
|
||||||
|
|
||||||
|
if (key.toLowerCase().includes("id"))
|
||||||
|
return <Hash className="w-3.5 h-3.5 text-muted-foreground" />;
|
||||||
|
if (key.toLowerCase().includes("host"))
|
||||||
|
return <Server className="w-3.5 h-3.5 text-muted-foreground" />;
|
||||||
|
if (isActualError)
|
||||||
|
return <AlertCircle className="w-3.5 h-3.5 text-destructive" />;
|
||||||
|
return <Database className="w-3.5 h-3.5 text-muted-foreground" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NotificationLogModal = ({
|
||||||
|
notificationLog,
|
||||||
|
}: NotificationLogModalProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
// TODO : to be fixed
|
||||||
|
// @ts-ignore
|
||||||
|
const payloadError = notificationLog.payload?.error;
|
||||||
|
const troubleshooting = getTroubleshootingForError(payloadError);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-xl bg-background">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Notification Details</DialogTitle>
|
||||||
|
<DialogDescription>Execution logs and payload data</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 mt-2 max-h-[70vh] overflow-y-auto px-1 pb-2">
|
||||||
|
<div className="relative border rounded-xl bg-card shadow-sm flex flex-col">
|
||||||
|
<div className="bg-primary/5 border-b px-4 py-2.5 flex items-center gap-2 rounded-t-xl">
|
||||||
|
<div className="p-1 bg-primary/10 rounded-md">
|
||||||
|
<Database className="w-4 h-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-sm text-primary">
|
||||||
|
Event Trigger
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-1 sm:gap-4">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground min-w-20 pt-0.5">
|
||||||
|
Title
|
||||||
|
</span>
|
||||||
|
<span className="text-sm sm:text-right flex-1 wrap-break-words">
|
||||||
|
{notificationLog.content.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-1 sm:gap-4">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground min-w-20 pt-0.5">
|
||||||
|
Message
|
||||||
|
</span>
|
||||||
|
<span className="text-sm sm:text-right flex-1 wrap-break-word text-muted-foreground">
|
||||||
|
{notificationLog.content.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{notificationLog.payload && (
|
||||||
|
<div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-primary rounded-full border-2 border-background z-10" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-border mx-auto -my-4 relative z-0" />
|
||||||
|
|
||||||
|
{notificationLog.payload &&
|
||||||
|
Object.keys(notificationLog.payload).length > 0 && (
|
||||||
|
<div className="relative border rounded-xl bg-card shadow-sm flex flex-col">
|
||||||
|
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-primary rounded-full border-2 border-background z-10" />
|
||||||
|
|
||||||
|
<div className="bg-primary/5 border-b px-4 py-2.5 flex items-center justify-between rounded-t-xl">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1 bg-primary/10 rounded-md">
|
||||||
|
<Braces className="w-4 h-4 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
|
<span className="font-semibold text-sm text-primary">
|
||||||
|
JSON Payload
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{notificationLog.payload && (
|
<div className="flex flex-col">
|
||||||
<>
|
{Object.entries(notificationLog.payload).map(
|
||||||
<Separator className="mb-3" />
|
([key, value], index, arr) => {
|
||||||
<div className="text-sm font-mono bg-gray-100 dark:bg-gray-800 dark:text-gray-200 p-4 rounded-md overflow-auto">
|
const isActualError =
|
||||||
{JSON.stringify(notificationLog.payload, null, 2)}
|
key === "error" &&
|
||||||
</div>
|
value !== null &&
|
||||||
</>
|
String(value).toLowerCase() !== "null";
|
||||||
)}
|
|
||||||
</DialogHeader>
|
return (
|
||||||
</DialogContent>
|
<div
|
||||||
</Dialog>
|
key={key}
|
||||||
)
|
className={`flex flex-col sm:flex-row sm:items-center justify-between gap-2 px-4 py-3 hover:bg-muted/50 transition-colors ${
|
||||||
}
|
index !== arr.length - 1
|
||||||
|
? "border-b border-border/50"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-[150px]">
|
||||||
|
{getIconForKey(key, value)}
|
||||||
|
<span className="text-sm font-medium">{key}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 sm:text-right">
|
||||||
|
{isActualError ? (
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-md bg-destructive/10 text-destructive text-xs font-mono break-all">
|
||||||
|
{String(value)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground font-mono bg-muted px-2 py-1 rounded-md break-all">
|
||||||
|
{String(value)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{troubleshooting && (
|
||||||
|
<div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-primary rounded-full border-2 border-background z-10" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{troubleshooting && (
|
||||||
|
<>
|
||||||
|
<div className="w-px h-6 bg-border mx-auto -my-4 relative z-0" />
|
||||||
|
|
||||||
|
<div className="relative border border-destructive/20 rounded-xl bg-gradient-to-b from-destructive/5 to-transparent shadow-sm flex flex-col">
|
||||||
|
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-destructive rounded-full border-2 border-background z-10" />
|
||||||
|
|
||||||
|
<div className="bg-destructive/10 border-b border-destructive/10 px-4 py-2.5 flex items-center justify-between rounded-t-xl">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1 bg-destructive/20 rounded-md">
|
||||||
|
<Sparkles className="w-4 h-4 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-sm text-destructive">
|
||||||
|
Suggested Resolution
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 flex flex-col gap-2">
|
||||||
|
<h4 className="text-sm font-semibold text-foreground">
|
||||||
|
{troubleshooting.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{troubleshooting.resolution}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
<a
|
||||||
|
href={troubleshooting.docLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs font-semibold text-destructive hover:text-destructive/80 transition-colors"
|
||||||
|
>
|
||||||
|
Read the documentation{" "}
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,49 +1,72 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import {useRouter} from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {toast} from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {backupButtonAction} from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
|
import { backupButtonAction } from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
|
||||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
import { Check, DatabaseZap, X } from "lucide-react";
|
||||||
import {Database, DatabaseZap} from "lucide-react";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import {useIsMobile} from "@/hooks/use-mobile";
|
import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm";
|
||||||
|
|
||||||
export type BackupButtonProps = {
|
export type BackupButtonProps = {
|
||||||
databaseId: string;
|
databaseId: string;
|
||||||
disable: boolean;
|
disable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BackupButton = (props: BackupButtonProps) => {
|
export const BackupButton = (props: BackupButtonProps) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (databaseId: string) => {
|
mutationFn: async (databaseId: string) => {
|
||||||
const backup = await backupButtonAction(databaseId);
|
const backup = await backupButtonAction(databaseId);
|
||||||
if (backup?.data?.success) {
|
if (backup?.data?.success) {
|
||||||
toast.success(backup.data.actionSuccess?.message || "Backup created successfully!");
|
toast.success(
|
||||||
queryClient.invalidateQueries({queryKey: ["database-data", props.databaseId]});
|
backup.data.actionSuccess?.message || "Backup created successfully!",
|
||||||
router.refresh();
|
);
|
||||||
} else {
|
queryClient.invalidateQueries({
|
||||||
toast.error(backup?.serverError || "Failed to create backup.");
|
queryKey: ["database-data", props.databaseId],
|
||||||
}
|
});
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error(backup?.serverError || "Failed to create backup.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const HandleAction = async () => {
|
||||||
|
await mutation.mutateAsync(props.databaseId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonWithConfirm
|
||||||
|
title="Create Backup"
|
||||||
|
description={"Are you sure you want to create a backup?"}
|
||||||
|
button={{
|
||||||
|
main: {
|
||||||
|
disabled: props.disable,
|
||||||
|
text: isMobile ? "" : "Backup",
|
||||||
|
variant: "default",
|
||||||
|
icon: <DatabaseZap />,
|
||||||
},
|
},
|
||||||
});
|
confirm: {
|
||||||
const HandleAction = async () => {
|
className: "w-full",
|
||||||
await mutation.mutateAsync(props.databaseId);
|
text: "Yes, create backup",
|
||||||
};
|
icon: <Check />,
|
||||||
|
variant: "default",
|
||||||
return (
|
onClick: async () => {
|
||||||
<ButtonWithLoading
|
await HandleAction();
|
||||||
icon={<DatabaseZap/>}
|
},
|
||||||
disabled={props.disable}
|
},
|
||||||
isPending={mutation.isPending}
|
cancel: {
|
||||||
size={"default"}
|
className: "w-full",
|
||||||
onClick={async () => {
|
text: "No, cancel",
|
||||||
await HandleAction();
|
icon: <X />,
|
||||||
}}
|
variant: "outline",
|
||||||
>{isMobile ? "" : "Backup"}</ButtonWithLoading>
|
},
|
||||||
);
|
}}
|
||||||
|
isPending={mutation.isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
|
||||||
import { GearIcon } from "@radix-ui/react-icons";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
|
|
||||||
export type EditButtonProps = {};
|
|
||||||
|
|
||||||
export const EditButton = (props: EditButtonProps) => {
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link className={buttonVariants({ variant: "outline" })} href={`${pathname}/edit`}>
|
|
||||||
<GearIcon className="w-7 h-7" />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,144 +1,158 @@
|
|||||||
"use client"
|
"use client";
|
||||||
import {DatabaseBackupActionsModal} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions-modal";
|
import { DatabaseBackupActionsModal } from "@/components/wrappers/dashboard/database/backup/actions/backup-actions-modal";
|
||||||
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs";
|
import { DatabaseTabs } from "@/components/wrappers/dashboard/projects/database/database-tabs";
|
||||||
import {Setting} from "@/db/schema/01_setting";
|
import { Setting } from "@/db/schema/01_setting";
|
||||||
import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database";
|
import { BackupWith, DatabaseWith, Restoration } from "@/db/schema/07_database";
|
||||||
import {MemberWithUser} from "@/db/schema/03_organization";
|
import { MemberWithUser } from "@/db/schema/03_organization";
|
||||||
import {useBackupModal} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
import { useBackupModal } from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
||||||
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
|
import { DatabaseKpi } from "@/components/wrappers/dashboard/projects/database/database-kpi";
|
||||||
import {useQuery, useQueryClient} from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {getDatabaseDataAction} from "@/components/wrappers/dashboard/database/backup/actions/get-data.action";
|
import { getDatabaseDataAction } from "@/components/wrappers/dashboard/database/backup/actions/get-data.action";
|
||||||
import {PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
import {
|
||||||
import {capitalizeFirstLetter} from "@/utils/text";
|
PageContent,
|
||||||
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
|
PageDescription,
|
||||||
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
|
PageTitle,
|
||||||
import {ChannelPoliciesModal} from "@/components/wrappers/dashboard/database/channels-policy/policy-modal";
|
} from "@/features/layout/page";
|
||||||
import {HardDrive, Megaphone} from "lucide-react";
|
import { capitalizeFirstLetter } from "@/utils/text";
|
||||||
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
|
import { RetentionPolicySheet } from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
|
||||||
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
|
import { CronButton } from "@/components/wrappers/dashboard/database/cron-button/cron-button";
|
||||||
|
import { ChannelPoliciesModal } from "@/components/wrappers/dashboard/database/channels-policy/policy-modal";
|
||||||
|
import { HardDrive, Megaphone } from "lucide-react";
|
||||||
|
import { ImportModal } from "@/components/wrappers/dashboard/database/import/import-modal";
|
||||||
|
import { BackupButton } from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
|
||||||
|
|
||||||
export type DatabaseContentProps = {
|
export type DatabaseContentProps = {
|
||||||
settings: Setting,
|
settings: Setting;
|
||||||
backups: BackupWith[],
|
backups: BackupWith[];
|
||||||
restorations: Restoration[],
|
restorations: Restoration[];
|
||||||
isAlreadyRestore: boolean,
|
isAlreadyRestore: boolean;
|
||||||
database: DatabaseWith,
|
database: DatabaseWith;
|
||||||
activeMember: MemberWithUser,
|
activeMember: MemberWithUser;
|
||||||
totalBackups: number,
|
totalBackups: number;
|
||||||
availableBackups: number,
|
availableBackups: number;
|
||||||
successRate: number | null,
|
successRate: number | null;
|
||||||
organizationId: string,
|
organizationId: string;
|
||||||
activeOrganizationChannels: any[],
|
activeOrganizationChannels: any[];
|
||||||
activeOrganizationStorageChannels: any[]
|
activeOrganizationStorageChannels: any[];
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
export const DatabaseContent = (props: DatabaseContentProps) => {
|
export const DatabaseContent = (props: DatabaseContentProps) => {
|
||||||
const {} = useBackupModal();
|
const {} = useBackupModal();
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
const {data} = useQuery({
|
queryKey: ["database-data", props.database.id],
|
||||||
queryKey: ["database-data", props.database.id],
|
queryFn: async () => {
|
||||||
queryFn: async () => {
|
const result = await getDatabaseDataAction({
|
||||||
const result = await getDatabaseDataAction({databaseId: props.database.id});
|
databaseId: props.database.id,
|
||||||
return result?.data;
|
});
|
||||||
},
|
return result?.data;
|
||||||
initialData: {
|
},
|
||||||
// TODO : to be patched
|
initialData: {
|
||||||
// @ts-ignore
|
// TODO : to be patched
|
||||||
database: {
|
// @ts-ignore
|
||||||
...props.database,
|
database: {
|
||||||
project: props.database.project ?? null,
|
...props.database,
|
||||||
},
|
project: props.database.project ?? null,
|
||||||
backups: props.backups,
|
},
|
||||||
restorations: props.restorations,
|
backups: props.backups,
|
||||||
activeOrganizationChannels: props.activeOrganizationChannels,
|
restorations: props.restorations,
|
||||||
activeOrganizationStorageChannels: props.activeOrganizationStorageChannels,
|
activeOrganizationChannels: props.activeOrganizationChannels,
|
||||||
stats: {
|
activeOrganizationStorageChannels:
|
||||||
totalBackups: props.totalBackups,
|
props.activeOrganizationStorageChannels,
|
||||||
availableBackups: props.availableBackups,
|
stats: {
|
||||||
successRate: props.successRate
|
|
||||||
}
|
|
||||||
},
|
|
||||||
staleTime: 0,
|
|
||||||
gcTime: 0,
|
|
||||||
refetchInterval: 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const database = data?.database ?? props.database;
|
|
||||||
const backups = data?.backups ?? props.backups;
|
|
||||||
const restorations = data?.restorations ?? props.restorations;
|
|
||||||
const activeOrganizationChannels = data?.activeOrganizationChannels ?? props.activeOrganizationChannels;
|
|
||||||
const activeOrganizationStorageChannels = data?.activeOrganizationStorageChannels ?? props.activeOrganizationStorageChannels;
|
|
||||||
const stats = data?.stats ?? {
|
|
||||||
totalBackups: props.totalBackups,
|
totalBackups: props.totalBackups,
|
||||||
availableBackups: props.availableBackups,
|
availableBackups: props.availableBackups,
|
||||||
successRate: props.successRate
|
successRate: props.successRate,
|
||||||
};
|
},
|
||||||
|
},
|
||||||
|
staleTime: 0,
|
||||||
|
gcTime: 0,
|
||||||
|
refetchInterval: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
|
const database = data?.database ?? props.database;
|
||||||
const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
|
const backups = data?.backups ?? props.backups;
|
||||||
|
const restorations = data?.restorations ?? props.restorations;
|
||||||
|
const activeOrganizationChannels =
|
||||||
|
data?.activeOrganizationChannels ?? props.activeOrganizationChannels;
|
||||||
|
const activeOrganizationStorageChannels =
|
||||||
|
data?.activeOrganizationStorageChannels ??
|
||||||
|
props.activeOrganizationStorageChannels;
|
||||||
|
const stats = data?.stats ?? {
|
||||||
|
totalBackups: props.totalBackups,
|
||||||
|
availableBackups: props.availableBackups,
|
||||||
|
successRate: props.successRate,
|
||||||
|
};
|
||||||
|
|
||||||
const isMember = props.activeMember.role === "member";
|
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
|
||||||
|
const isAlreadyBackup = backups.some(
|
||||||
|
(b) => b.status === "waiting" || b.status === "ongoing",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
const isMember = props.activeMember.role === "member";
|
||||||
<>
|
|
||||||
<div className="justify-between gap-2 sm:flex">
|
return (
|
||||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
<>
|
||||||
<div className="min-w-full md:min-w-fit ">
|
<div className="justify-between gap-2 sm:flex">
|
||||||
{capitalizeFirstLetter(database.name)}
|
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||||
</div>
|
<div className="min-w-full md:min-w-fit ">
|
||||||
{!isMember && (
|
{capitalizeFirstLetter(database.name)}
|
||||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
{!isMember && (
|
||||||
<RetentionPolicySheet database={database}/>
|
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||||
<CronButton database={database}/>
|
<div className="flex items-center gap-2">
|
||||||
<ChannelPoliciesModal
|
<RetentionPolicySheet database={database} />
|
||||||
database={database}
|
<CronButton database={database} />
|
||||||
kind={"notification"}
|
<ChannelPoliciesModal
|
||||||
icon={<Megaphone/>}
|
database={database}
|
||||||
channels={activeOrganizationChannels}
|
kind={"notification"}
|
||||||
organizationId={props.organizationId}
|
icon={<Megaphone />}
|
||||||
/>
|
channels={activeOrganizationChannels}
|
||||||
<ChannelPoliciesModal
|
organizationId={props.organizationId}
|
||||||
database={database}
|
/>
|
||||||
icon={<HardDrive/>}
|
<ChannelPoliciesModal
|
||||||
kind={"storage"}
|
database={database}
|
||||||
channels={activeOrganizationStorageChannels}
|
icon={<HardDrive />}
|
||||||
organizationId={props.organizationId}
|
kind={"storage"}
|
||||||
/>
|
channels={activeOrganizationStorageChannels}
|
||||||
<ImportModal database={database}/>
|
organizationId={props.organizationId}
|
||||||
</div>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<ImportModal database={database} />
|
||||||
<BackupButton disable={isAlreadyBackup} databaseId={database.id}/>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<BackupButton
|
||||||
)}
|
disable={isAlreadyBackup}
|
||||||
</PageTitle>
|
databaseId={database.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</PageTitle>
|
||||||
|
</div>
|
||||||
|
|
||||||
{database.description && (
|
{database.description && (
|
||||||
<PageDescription className="mt-5 sm:mt-0">{database.description}</PageDescription>
|
<PageDescription className="mt-5 sm:mt-0">
|
||||||
)}
|
{database.description}
|
||||||
|
</PageDescription>
|
||||||
|
)}
|
||||||
|
|
||||||
<PageContent className="flex flex-col w-full h-full">
|
<PageContent className="flex flex-col w-full h-full">
|
||||||
<DatabaseKpi
|
<DatabaseKpi
|
||||||
successRate={stats.successRate}
|
successRate={stats.successRate}
|
||||||
database={database}
|
database={database}
|
||||||
availableBackups={stats.availableBackups}
|
availableBackups={stats.availableBackups}
|
||||||
totalBackups={stats.totalBackups}
|
totalBackups={stats.totalBackups}
|
||||||
/>
|
/>
|
||||||
<DatabaseBackupActionsModal/>
|
<DatabaseBackupActionsModal />
|
||||||
<DatabaseTabs
|
<DatabaseTabs
|
||||||
activeMember={props.activeMember}
|
activeMember={props.activeMember}
|
||||||
settings={props.settings}
|
settings={props.settings}
|
||||||
database={database}
|
database={database}
|
||||||
isAlreadyRestore={isAlreadyRestore}
|
isAlreadyRestore={isAlreadyRestore}
|
||||||
backups={backups}
|
backups={backups}
|
||||||
restorations={restorations}
|
restorations={restorations}
|
||||||
/>
|
/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
+2
-2
@@ -69,8 +69,8 @@ export const env = createEnv({
|
|||||||
AUTH_SYNC_OIDC_ROLES_ON_LOGIN: z.enum(["true", "false"]).default("true"),
|
AUTH_SYNC_OIDC_ROLES_ON_LOGIN: z.enum(["true", "false"]).default("true"),
|
||||||
AUTH_ROLE_MAP: z.string().optional(),
|
AUTH_ROLE_MAP: z.string().optional(),
|
||||||
AUTH_DEFAULT_ROLE: z.string().optional().default("pending"),
|
AUTH_DEFAULT_ROLE: z.string().optional().default("pending"),
|
||||||
AUTH_ALLOW_LINKING: z.enum(["true", "false"]).default("false"),
|
AUTH_ALLOW_LINKING: z.enum(["true", "false"]).default("true"),
|
||||||
AUTH_ALLOW_UNLINKING: z.enum(["true", "false"]).default("false"),
|
AUTH_ALLOW_UNLINKING: z.enum(["true", "false"]).default("true"),
|
||||||
|
|
||||||
PRIVATE_PATH: z.string().optional(),
|
PRIVATE_PATH: z.string().optional(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,148 +2,183 @@
|
|||||||
|
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {MoreHorizontal, Trash2} from "lucide-react";
|
import { Check, MoreHorizontal, Trash2, X } from "lucide-react";
|
||||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||||
import { StatusBadge } from "@/components/wrappers/common/status-badge";
|
import { StatusBadge } from "@/components/wrappers/common/status-badge";
|
||||||
import {Restoration} from "@/db/schema/07_database";
|
import { Restoration } from "@/db/schema/07_database";
|
||||||
import {formatLocalizedDate} from "@/utils/date-formatting";
|
import { formatLocalizedDate } from "@/utils/date-formatting";
|
||||||
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
deleteRestoreAction,
|
deleteRestoreAction,
|
||||||
rerunRestorationAction
|
rerunRestorationAction,
|
||||||
} from "@/features/dashboard/restore/restore.action";
|
} from "@/features/dashboard/restore/restore.action";
|
||||||
import {toast} from "sonner";
|
import { toast } from "sonner";
|
||||||
import {TooltipCustom} from "@/components/wrappers/common/tooltip-custom";
|
import { TooltipCustom } from "@/components/wrappers/common/tooltip-custom";
|
||||||
import {MemberWithUser} from "@/db/schema/03_organization";
|
import { MemberWithUser } from "@/db/schema/03_organization";
|
||||||
|
import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm";
|
||||||
|
|
||||||
export function restoreColumns(
|
export function restoreColumns(
|
||||||
isAlreadyRestore: boolean,
|
isAlreadyRestore: boolean,
|
||||||
activeMember: MemberWithUser
|
activeMember: MemberWithUser,
|
||||||
): ColumnDef<Restoration>[] {
|
): ColumnDef<Restoration>[] {
|
||||||
return[
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: "id",
|
accessorKey: "id",
|
||||||
header: "Reference",
|
header: "Reference",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "createdAt",
|
accessorKey: "createdAt",
|
||||||
header: "Created At",
|
header: "Created At",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return formatLocalizedDate(row.getValue("createdAt"))
|
return formatLocalizedDate(row.getValue("createdAt"));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "status",
|
accessorKey: "status",
|
||||||
header: "Status",
|
header: "Status",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return <StatusBadge status={row.getValue("status")} />;
|
return <StatusBadge status={row.getValue("status")} />;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const status = row.getValue("status");
|
const status = row.getValue("status");
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const rowData: Restoration = row.original;
|
const rowData: Restoration = row.original;
|
||||||
|
|
||||||
|
const mutationDeleteRestore = useMutation({
|
||||||
const mutationDeleteRestore = useMutation({
|
mutationFn: async () => {
|
||||||
mutationFn: async () => {
|
const restoration = await deleteRestoreAction({
|
||||||
const restoration = await deleteRestoreAction({
|
restorationId: rowData.id,
|
||||||
restorationId: rowData.id,
|
|
||||||
});
|
|
||||||
// @ts-ignore
|
|
||||||
if (restoration.data.success) {
|
|
||||||
// @ts-ignore
|
|
||||||
toast.success(restoration.data.actionSuccess.message);
|
|
||||||
queryClient.invalidateQueries({queryKey: ["database-data", rowData.databaseId]});
|
|
||||||
} else {
|
|
||||||
// @ts-ignore
|
|
||||||
toast.error(restoration.data.actionError.message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
// @ts-ignore
|
||||||
const mutationRerunRestore = useMutation({
|
if (restoration.data.success) {
|
||||||
mutationFn: async () => {
|
// @ts-ignore
|
||||||
const restoration = await rerunRestorationAction({
|
toast.success(restoration.data.actionSuccess.message);
|
||||||
restorationId: rowData.id,
|
queryClient.invalidateQueries({
|
||||||
});
|
queryKey: ["database-data", rowData.databaseId],
|
||||||
// @ts-ignore
|
});
|
||||||
if (restoration.data.success) {
|
} else {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
toast.success(restoration.data.actionSuccess.message);
|
toast.error(restoration.data.actionError.message);
|
||||||
queryClient.invalidateQueries({queryKey: ["database-data", rowData.databaseId]});
|
|
||||||
} else {
|
|
||||||
// @ts-ignore
|
|
||||||
toast.error(restoration.data.actionError.message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
await mutationDeleteRestore.mutateAsync();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRerunRestore = async () => {
|
|
||||||
await mutationRerunRestore.mutateAsync();
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutationRerunRestore = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const restoration = await rerunRestorationAction({
|
||||||
|
restorationId: rowData.id,
|
||||||
|
});
|
||||||
|
// @ts-ignore
|
||||||
|
if (restoration.data.success) {
|
||||||
|
// @ts-ignore
|
||||||
|
toast.success(restoration.data.actionSuccess.message);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["database-data", rowData.databaseId],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
toast.error(restoration.data.actionError.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
const handleDelete = async () => {
|
||||||
<>
|
await mutationDeleteRestore.mutateAsync();
|
||||||
{activeMember.role != "member" && (
|
};
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
const handleRerunRestore = async () => {
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0" type="button" onClick={(e) => e.stopPropagation()}>
|
await mutationRerunRestore.mutateAsync();
|
||||||
<span className="sr-only">Open menu</span>
|
};
|
||||||
<MoreHorizontal className="h-4 w-4"/>
|
|
||||||
</Button>
|
return (
|
||||||
</DropdownMenuTrigger>
|
<>
|
||||||
<DropdownMenuContent align="end">
|
{activeMember.role !== "member" && (
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
<DropdownMenu>
|
||||||
{rowData.backupStorageId && (
|
<DropdownMenuTrigger asChild>
|
||||||
<TooltipCustom disabled={isAlreadyRestore} text="Already a restoration waiting">
|
<Button
|
||||||
<DropdownMenuItem
|
variant="ghost"
|
||||||
disabled={mutationRerunRestore.isPending || isAlreadyRestore}
|
className="h-8 w-8 p-0"
|
||||||
onClick={async () => {
|
type="button"
|
||||||
await handleRerunRestore();
|
onClick={(e) => e.stopPropagation()}
|
||||||
}}
|
>
|
||||||
>
|
<span className="sr-only">Open menu</span>
|
||||||
<ReloadIcon/> Rerun
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
</TooltipCustom>
|
</DropdownMenuTrigger>
|
||||||
)}
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuSeparator/>
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
<DropdownMenuItem
|
{rowData.backupStorageId && (
|
||||||
disabled={status == "waiting"}
|
<TooltipCustom
|
||||||
className="text-red-600"
|
disabled={isAlreadyRestore}
|
||||||
onClick={async () => {
|
text="Already a restoration waiting"
|
||||||
await handleDelete();
|
>
|
||||||
}}
|
<DropdownMenuItem
|
||||||
>
|
disabled={
|
||||||
<Trash2/> Delete
|
mutationRerunRestore.isPending || isAlreadyRestore
|
||||||
</DropdownMenuItem>
|
}
|
||||||
</DropdownMenuContent>
|
onClick={async () => {
|
||||||
</DropdownMenu>
|
await handleRerunRestore();
|
||||||
)}
|
}}
|
||||||
</>
|
>
|
||||||
);
|
<ReloadIcon /> Rerun
|
||||||
},
|
</DropdownMenuItem>
|
||||||
|
</TooltipCustom>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<ButtonWithConfirm
|
||||||
|
title="Delete Restoration"
|
||||||
|
description={
|
||||||
|
"Are you sure you want to delete this restoration?"
|
||||||
|
}
|
||||||
|
button={{
|
||||||
|
main: {
|
||||||
|
text: "Delete",
|
||||||
|
variant: "ghost",
|
||||||
|
icon: (
|
||||||
|
<Trash2 className="text-red-500 size-4 mr-px" />
|
||||||
|
),
|
||||||
|
disabled: status === "waiting",
|
||||||
|
className:
|
||||||
|
"text-red-500 hover:text-red-500 p-0 h-auto has-[>svg]:px-0",
|
||||||
|
},
|
||||||
|
confirm: {
|
||||||
|
className: "w-full",
|
||||||
|
text: "Yes, delete",
|
||||||
|
icon: <Check />,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: async () => {
|
||||||
|
await handleDelete();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancel: {
|
||||||
|
className: "w-full",
|
||||||
|
text: "No, cancel",
|
||||||
|
icon: <X />,
|
||||||
|
variant: "outline",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
isPending={mutationDeleteRestore.isPending}
|
||||||
|
/>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,36 @@
|
|||||||
import type {EventPayload, DispatchResult} from '../types';
|
import type {EventPayload, DispatchResult} from '../types';
|
||||||
|
|
||||||
|
const getPriority = (level?: string): number => {
|
||||||
|
switch (level) {
|
||||||
|
case 'critical': return 5;
|
||||||
|
case 'error': return 4;
|
||||||
|
case 'warning': return 3;
|
||||||
|
case 'info':
|
||||||
|
default: return 2;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatData = (data: any): string => {
|
||||||
|
if (!data || typeof data !== 'object') return '';
|
||||||
|
return '\n\nDetails:\n' + Object.entries(data)
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const stringVal = value !== null && typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||||
|
return `- ${key}: ${stringVal}`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTags = (level?: string): string[] => {
|
||||||
|
const baseTags = ['floppy_disk'];
|
||||||
|
|
||||||
|
if (level === 'critical') baseTags.push('rotating_light');
|
||||||
|
else if (level === 'error') baseTags.push('x');
|
||||||
|
else if (level === 'warning') baseTags.push('warning');
|
||||||
|
else baseTags.push('information_source');
|
||||||
|
|
||||||
|
return baseTags;
|
||||||
|
};
|
||||||
|
|
||||||
export async function sendNtfy(
|
export async function sendNtfy(
|
||||||
config: { ntfyServerUrl?: string; ntfyTopic: string; ntfyToken?: string, ntfyUsername?: string, ntfyPassword?: string },
|
config: { ntfyServerUrl?: string; ntfyTopic: string; ntfyToken?: string, ntfyUsername?: string, ntfyPassword?: string },
|
||||||
payload: EventPayload
|
payload: EventPayload
|
||||||
@@ -11,9 +42,9 @@ export async function sendNtfy(
|
|||||||
const body = {
|
const body = {
|
||||||
topic: ntfyTopic,
|
topic: ntfyTopic,
|
||||||
title: payload.title,
|
title: payload.title,
|
||||||
message: payload.message + (payload.data ? `\n\nData:\n${JSON.stringify(payload.data, null, 2)}` : ''),
|
message: payload.message + formatData(payload.data),
|
||||||
priority: 1,
|
priority: getPriority(payload.level),
|
||||||
tags: [payload.level === 'critical' ? 'rotating_light' : payload.level === 'warning' ? 'warning' : 'information_source'],
|
tags: getTags(payload.level),
|
||||||
};
|
};
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
|
|||||||
@@ -1,49 +1,62 @@
|
|||||||
import type {EventPayload, DispatchResult} from '../types';
|
import type { EventPayload, DispatchResult } from "../types";
|
||||||
|
|
||||||
export async function sendTelegram(
|
export async function sendTelegram(
|
||||||
config: { telegramBotToken: string; telegramChatId: string },
|
config: {
|
||||||
payload: EventPayload
|
telegramBotToken: string;
|
||||||
|
telegramChatId: string;
|
||||||
|
telegramTopicId?: string;
|
||||||
|
},
|
||||||
|
payload: EventPayload,
|
||||||
): Promise<DispatchResult> {
|
): Promise<DispatchResult> {
|
||||||
const {telegramBotToken, telegramChatId} = config;
|
const { telegramBotToken, telegramChatId, telegramTopicId } = config;
|
||||||
|
|
||||||
// Helper to escape HTML characters
|
// Helper to escape HTML characters
|
||||||
const escapeHtml = (unsafe: string) => {
|
const escapeHtml = (unsafe: string) => {
|
||||||
return unsafe
|
return unsafe
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
.replace(/</g, "<")
|
.replace(/</g, "<")
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, ">")
|
||||||
.replace(/"/g, """)
|
.replace(/"/g, """)
|
||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
};
|
};
|
||||||
|
|
||||||
const title = escapeHtml(payload.title);
|
const title = escapeHtml(payload.title);
|
||||||
const message = escapeHtml(payload.message);
|
const message = escapeHtml(payload.message);
|
||||||
const level = escapeHtml(payload.level.toUpperCase());
|
const level = escapeHtml(payload.level.toUpperCase());
|
||||||
const dataString = payload.data ? escapeHtml(JSON.stringify(payload.data, null, 2).substring(0, 1000)) : '';
|
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 text = `<b>${title}</b>\n\n${message}\n\nLevel: <code>${level}</code>${payload.data ? `\n\nData:\n<pre>${dataString}</pre>` : ""}`;
|
||||||
|
|
||||||
const body = {
|
const body: Record<string, any> = {
|
||||||
chat_id: telegramChatId,
|
chat_id: telegramChatId,
|
||||||
text,
|
text,
|
||||||
parse_mode: 'HTML',
|
parse_mode: "HTML",
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await fetch(`https://api.telegram.org/bot${telegramBotToken}/sendMessage`, {
|
if (telegramTopicId) {
|
||||||
method: 'POST',
|
body.message_thread_id = Number(telegramTopicId);
|
||||||
body: JSON.stringify(body),
|
}
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
const res = await fetch(
|
||||||
const err = await res.text();
|
`https://api.telegram.org/bot${telegramBotToken}/sendMessage`,
|
||||||
throw new Error(`Telegram error: ${res.status} ${err}`);
|
{
|
||||||
}
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
if (!res.ok) {
|
||||||
success: true,
|
const err = await res.text();
|
||||||
provider: 'telegram',
|
throw new Error(`Telegram error: ${res.status} ${err}`);
|
||||||
message: 'Sent to Telegram',
|
}
|
||||||
response: await res.text(),
|
|
||||||
};
|
return {
|
||||||
|
success: true,
|
||||||
|
provider: "telegram",
|
||||||
|
message: "Sent to Telegram",
|
||||||
|
response: await res.text(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user