mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be01031501 | ||
|
|
2e656d9c41 | ||
|
|
9071193c4d | ||
|
|
27e1da6363 | ||
|
|
e02c5496ae | ||
|
|
3fee236fd0 | ||
|
|
78ba42239d | ||
|
|
8ffb672e26 | ||
|
|
83ddc934ba | ||
|
|
31cbd52703 | ||
|
|
dd85f7a25d | ||
|
|
203c25aabe | ||
|
|
41b1c89283 | ||
|
|
180ea21041 | ||
|
|
e1c0fc6917 | ||
|
|
057c192314 | ||
|
|
aff782aa28 | ||
|
|
2e729cb79c | ||
|
|
d8677d74a7 | ||
|
|
2f77714994 | ||
|
|
bc8050c4df | ||
|
|
a9c69b112d | ||
|
|
04785c323b | ||
|
|
1d209ceb28 | ||
|
|
941a9256f0 | ||
|
|
9098012426 | ||
|
|
8e12ff59a4 | ||
|
|
0f8a267861 | ||
|
|
da53a00fd8 | ||
|
|
47e3a93e42 | ||
|
|
0262a1c176 | ||
|
|
7aecd911c0 | ||
|
|
595ccadfb3 | ||
|
|
bded273f94 | ||
|
|
c095d393cf | ||
|
|
c3ae82fde7 | ||
|
|
a8e11ebef7 | ||
|
|
61b1a2e724 | ||
|
|
7a4af4e1e0 | ||
|
|
a9d6481193 | ||
|
|
fb97963cc3 | ||
|
|
0cc0ba989c | ||
|
|
26eff02943 | ||
|
|
67246efafc | ||
|
|
260b3ba1f9 | ||
|
|
90f8885e73 | ||
|
|
0bacd6a65c | ||
|
|
2846deb4d0 | ||
|
|
ae6dbcab25 | ||
|
|
d823ec2e82 | ||
|
|
b0ce5273ce | ||
|
|
b576862024 | ||
|
|
dc2496ee18 | ||
|
|
87db207833 | ||
|
|
b1ccf6ec5f | ||
|
|
ca3f63af81 | ||
|
|
29b0ca5bf5 | ||
|
|
da50193e8c | ||
|
|
1829c1fc1c | ||
|
|
79a17407bd | ||
|
|
dd1e3283d5 | ||
|
|
d1baedd092 | ||
|
|
9e504cefc1 | ||
|
|
3cc5ab70fa | ||
|
|
0441af78a1 | ||
|
|
3c615a4fd7 | ||
|
|
8681e16fde | ||
|
|
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 | ||
|
|
095444610d |
+3
-1
@@ -6,7 +6,6 @@ DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public
|
||||
|
||||
# Project
|
||||
PROJECT_NAME="Portabase"
|
||||
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
|
||||
PROJECT_URL=http://localhost:8887
|
||||
PROJECT_SECRET=
|
||||
|
||||
@@ -59,3 +58,6 @@ AUTH_PASSKEY_ENABLED=true
|
||||
|
||||
# Retention
|
||||
RETENTION_CRON="* * * * *"
|
||||
|
||||
TRUSTED_DOMAINS="http://localhost:8887, http://localhost:3055, http://localhost:3056"
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ representative at an online or offline event.
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
contact@soluce-technologies.com.
|
||||
contact@portabase.io.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
|
||||
@@ -72,7 +72,7 @@ If you encounter a bug or have a suggestion for improvement, follow these steps:
|
||||
|
||||
3. **Push your branch**
|
||||
```bash
|
||||
git push origin feature/<feature-name>
|
||||
git push origin feat/<feature-name>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
- 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.
|
||||
- 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! 🙌
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ We take security seriously and aim to support the following versions of the proj
|
||||
If you discover a security vulnerability in this project, we appreciate your help in disclosing it responsibly.
|
||||
|
||||
1. **Contact Us**
|
||||
Please report the vulnerability by emailing **[contact@soluce-technologies.com](mailto:contact@soluce-technologies.com)**. Include the following details:
|
||||
Please report the vulnerability by emailing **[contact@portabase.io](mailto:contact@portabase.io)**. Include the following details:
|
||||
- A detailed description of the issue.
|
||||
- Steps to reproduce the vulnerability (if applicable).
|
||||
- Any potential impacts or risks.
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
name: Auto Release bot
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
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: ${{ github.base_ref }}
|
||||
|
||||
- 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]'
|
||||
- run: git config --global user.email 'github-actions[bot]@users.noreply.github.com'
|
||||
|
||||
- name: Run release-it
|
||||
run: |
|
||||
git pull origin main
|
||||
pnpm exec release-it --ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
@@ -3,6 +3,9 @@ name: Discord Notification
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_tag:
|
||||
required: true
|
||||
type: string
|
||||
discord_title:
|
||||
required: true
|
||||
type: string
|
||||
@@ -27,10 +30,10 @@ jobs:
|
||||
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_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="${{ github.ref_name }}"; fi
|
||||
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)
|
||||
|
||||
@@ -3,6 +3,12 @@ name: Docker Publish
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
ref:
|
||||
required: true
|
||||
type: string
|
||||
image_name:
|
||||
required: false
|
||||
type: string
|
||||
@@ -32,10 +38,13 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [linux/amd64, linux/arm64]
|
||||
platform: [ linux/amd64, linux/arm64 ]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -50,9 +59,10 @@ jobs:
|
||||
id: prep
|
||||
run: |
|
||||
ARCH=${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||
echo "image=${{ inputs.image_name }}:${GITHUB_REF#refs/tags/}-$ARCH" >> $GITHUB_OUTPUT
|
||||
echo "image=${{ inputs.image_name }}:${{inputs.version}}-$ARCH" >> $GITHUB_OUTPUT
|
||||
echo "safe_platform=${{ matrix.platform == 'linux/amd64' && 'linux-amd64' || 'linux-arm64' }}" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
@@ -95,20 +105,38 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
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=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable=${{ inputs.add_latest }}
|
||||
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
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)"
|
||||
TAG_ARGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")
|
||||
docker buildx imagetools create $DOCKER_IMAGES $TAG_ARGS
|
||||
echo $TAG_ARGS
|
||||
docker buildx imagetools create $TAG_ARGS $DOCKER_IMAGES
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
name: End-to-end testing
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/dockerfile/Dockerfile
|
||||
push: false
|
||||
load: true
|
||||
tags: portabase/portabase:test
|
||||
|
||||
- name: Run app container
|
||||
run: |
|
||||
docker run -d --name myapp \
|
||||
-p 8887:80 \
|
||||
-e NODE_ENV=production \
|
||||
-e PROJECT_URL=http://localhost:8887 \
|
||||
-e PROJECT_SECRET=80af5e4c875dfded3a6ba511c6ed8b0160cad723f848ee0851403ed6141f6ba1 \
|
||||
portabase/portabase:test
|
||||
|
||||
- name: Wait for app port to be listening
|
||||
run: |
|
||||
for i in {1..40}; do
|
||||
|
||||
if curl -fsS http://localhost:8887/ >/dev/null; then
|
||||
echo "App is responding on port 8887"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for app readiness... ($i/40)"
|
||||
sleep 2
|
||||
done
|
||||
echo "Timeout: app never became ready"
|
||||
docker logs myapp
|
||||
exit 1
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: pnpm exec playwright test
|
||||
env:
|
||||
PROJECT_URL: http://localhost:8887
|
||||
# E2E Notification
|
||||
E2E_NOTIFICATION_SMTP_HOST: ${{ secrets.E2E_NOTIFICATION_SMTP_HOST }}
|
||||
E2E_NOTIFICATION_SMTP_PORT: ${{ secrets.E2E_NOTIFICATION_SMTP_PORT }}
|
||||
E2E_NOTIFICATION_SMTP_USER: ${{ secrets.E2E_NOTIFICATION_SMTP_USER }}
|
||||
E2E_NOTIFICATION_SMTP_PASSWORD: ${{ secrets.E2E_NOTIFICATION_SMTP_PASSWORD }}
|
||||
E2E_NOTIFICATION_SMTP_FROM: ${{ secrets.E2E_NOTIFICATION_SMTP_FROM }}
|
||||
E2E_NOTIFICATION_SMTP_TO: ${{ secrets.E2E_NOTIFICATION_SMTP_TO }}
|
||||
E2E_NOTIFICATION_SLACK_WEBHOOK: ${{ secrets.E2E_NOTIFICATION_SLACK_WEBHOOK }}
|
||||
E2E_NOTIFICATION_DISCORD_WEBHOOK: ${{ secrets.E2E_NOTIFICATION_DISCORD_WEBHOOK }}
|
||||
E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN }}
|
||||
E2E_NOTIFICATION_TELEGRAM_CHAT_ID: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_CHAT_ID }}
|
||||
E2E_NOTIFICATION_TELEGRAM_TOPIC_ID: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_TOPIC_ID }}
|
||||
E2E_NOTIFICATION_GOTIFY_SERVER_URL: ${{ secrets.E2E_NOTIFICATION_GOTIFY_SERVER_URL }}
|
||||
E2E_NOTIFICATION_GOTIFY_APP_TOKEN: ${{ secrets.E2E_NOTIFICATION_GOTIFY_APP_TOKEN }}
|
||||
E2E_NOTIFICATION_NTFY_TOPIC: ${{ secrets.E2E_NOTIFICATION_NTFY_TOPIC }}
|
||||
E2E_NOTIFICATION_NTFY_SERVER_URL: ${{ secrets.E2E_NOTIFICATION_NTFY_SERVER_URL }}
|
||||
E2E_NOTIFICATION_NTFY_TOKEN: ${{ secrets.E2E_NOTIFICATION_NTFY_TOKEN }}
|
||||
E2E_NOTIFICATION_NTFY_USERNAME: ${{ secrets.E2E_NOTIFICATION_NTFY_USERNAME }}
|
||||
E2E_NOTIFICATION_NTFY_PASSWORD: ${{ secrets.E2E_NOTIFICATION_NTFY_PASSWORD }}
|
||||
E2E_NOTIFICATION_WEBHOOK_URL: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_URL }}
|
||||
E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER }}
|
||||
E2E_NOTIFICATION_WEBHOOK_SECRET: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_SECRET }}
|
||||
|
||||
# E2E Storage
|
||||
E2E_STORAGE_AWS_S3_ENDPOINT_URL: ${{ secrets.E2E_STORAGE_AWS_S3_ENDPOINT_URL }}
|
||||
E2E_STORAGE_AWS_S3_REGION: ${{ secrets.E2E_STORAGE_AWS_S3_REGION }}
|
||||
E2E_STORAGE_AWS_S3_ACCESS_KEY: ${{ secrets.E2E_STORAGE_AWS_S3_ACCESS_KEY }}
|
||||
E2E_STORAGE_AWS_S3_SECRET_KEY: ${{ secrets.E2E_STORAGE_AWS_S3_SECRET_KEY }}
|
||||
E2E_STORAGE_AWS_S3_BUCKET_NAME: ${{ secrets.E2E_STORAGE_AWS_S3_BUCKET_NAME }}
|
||||
E2E_STORAGE_AWS_S3_PORT: ${{ secrets.E2E_STORAGE_AWS_S3_PORT }}
|
||||
E2E_STORAGE_R2_ENDPOINT_URL: ${{ secrets.E2E_STORAGE_R2_ENDPOINT_URL }}
|
||||
E2E_STORAGE_R2_REGION: ${{ secrets.E2E_STORAGE_R2_REGION }}
|
||||
E2E_STORAGE_R2_ACCESS_KEY: ${{ secrets.E2E_STORAGE_R2_ACCESS_KEY }}
|
||||
E2E_STORAGE_R2_SECRET_KEY: ${{ secrets.E2E_STORAGE_R2_SECRET_KEY }}
|
||||
E2E_STORAGE_R2_BUCKET_NAME: ${{ secrets.E2E_STORAGE_R2_BUCKET_NAME }}
|
||||
E2E_STORAGE_R2_PORT: ${{ secrets.E2E_STORAGE_R2_PORT }}
|
||||
E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID }}
|
||||
E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET }}
|
||||
E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID }}
|
||||
|
||||
- name: Upload report if failed
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: docker stop myapp && docker rm myapp || true
|
||||
@@ -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
|
||||
@@ -1,47 +0,0 @@
|
||||
name: PR Checker
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
validate-code:
|
||||
name: Code Integrity Check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check code formatting
|
||||
run: pnpm run format:check
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run Linter
|
||||
run: pnpm run lint
|
||||
continue-on-error: true
|
||||
|
||||
- name: Type Check
|
||||
run: pnpm run typecheck
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run Tests
|
||||
run: pnpm run test
|
||||
continue-on-error: true
|
||||
|
||||
- name: Test Application Build
|
||||
run: pnpm run build
|
||||
+123
-11
@@ -1,31 +1,143 @@
|
||||
name: Publish Docker image for release
|
||||
name: Auto Release & Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*.*.*"
|
||||
- "!*-*"
|
||||
pull_request:
|
||||
types: [ closed ]
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
docker_publish:
|
||||
|
||||
check-skip:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
skip: ${{ steps.set-skip.outputs.skip }}
|
||||
steps:
|
||||
- name: Determine if release should be skipped
|
||||
id: set-skip
|
||||
run: |
|
||||
TITLE="${{ github.event.pull_request.title }}"
|
||||
echo "PR title: $TITLE"
|
||||
|
||||
if [[ "$TITLE" == *"[skip-release]"* ]]; then
|
||||
echo "PR title contains [skip-release], skipping release jobs."
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
create-release:
|
||||
needs: check-skip
|
||||
if: ${{ needs.check-skip.outputs.skip == 'false' }}
|
||||
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
|
||||
if: ${{ needs.create-release.result == 'success' }}
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
version: ${{ needs.create-release.outputs.version }}
|
||||
ref: ${{ needs.create-release.outputs.version }}
|
||||
add_latest: true
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||
|
||||
discord_notification:
|
||||
needs: docker_publish
|
||||
publish-helm:
|
||||
needs: create-release
|
||||
if: ${{ needs.create-release.result == 'success' }}
|
||||
uses: ./.github/workflows/helm.yml
|
||||
with:
|
||||
version: ${{ needs.create-release.outputs.version }}
|
||||
secrets:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
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:
|
||||
discord_title: "New Release: ${{ github.ref_name }}"
|
||||
release_tag: ${{ needs.create-release.outputs.version }}
|
||||
discord_title: "New Release"
|
||||
discord_color: 3066993
|
||||
discord_footer: "Portabase"
|
||||
secrets:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -9,14 +9,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: aquasecurity/trivy-action@0.20.0
|
||||
- uses: aquasecurity/trivy-action@v0.35.0
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
format: 'table'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
ignore-unfixed: true
|
||||
|
||||
|
||||
secrets-gitleaks:
|
||||
if: github.event.pull_request.head.repo.fork == false
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -47,3 +47,11 @@ private/keys/*
|
||||
seeds/pocket-id/*.db
|
||||
|
||||
certificates
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
/e2e/local-storage.json
|
||||
|
||||
+38
-7
@@ -1,25 +1,56 @@
|
||||
{
|
||||
"github": {
|
||||
"release": true,
|
||||
"draft": true,
|
||||
"tokenRef": "GITHUB_TOKEN"
|
||||
},
|
||||
"git": {
|
||||
"commit": true,
|
||||
"commitMessage": "chore: release v${version}",
|
||||
"commitMessage": "chore: release ${version}",
|
||||
"requireCleanWorkingDir": true,
|
||||
"tag": true,
|
||||
"tagName": "${version}",
|
||||
"push": true
|
||||
},
|
||||
"github": {
|
||||
"release": true,
|
||||
"tokenRef": "GITHUB_TOKEN"
|
||||
},
|
||||
"plugins": {
|
||||
"@release-it/conventional-changelog": {
|
||||
"preset": "angular"
|
||||
"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/plain"
|
||||
"type": "text/yaml"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-4
@@ -1,6 +1,6 @@
|
||||
cff-version: 1.2.0
|
||||
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
|
||||
authors:
|
||||
- family-names: Gauthereau
|
||||
@@ -9,7 +9,12 @@ authors:
|
||||
given-names: Killian
|
||||
repository-code: https://github.com/Portabase/portabase
|
||||
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:
|
||||
- docker
|
||||
- kubernetes
|
||||
@@ -26,5 +31,5 @@ keywords:
|
||||
- web-ui
|
||||
- agent
|
||||
license: Apache-2.0
|
||||
version: 1.4.3
|
||||
date-released: "2026-03-02"
|
||||
version: 1.11.1
|
||||
date-released: '2026-03-02'
|
||||
|
||||
@@ -46,3 +46,17 @@ export-keycloak:
|
||||
@docker rm -f kc-exporter >/dev/null 2>&1
|
||||
@docker compose -f docker-compose.func.yml start keycloak >/dev/null 2>&1
|
||||
@echo "Keycloak configuration and users exported to seeds/keycloak/*.json"
|
||||
|
||||
end-to-end:
|
||||
@echo "Starting E2E testing..."
|
||||
@docker compose -f docker-compose.e2e.yml up -d
|
||||
@CI=true PROJECT_URL=http://localhost:8887 pnpm playwright test --project=chromium || (docker compose -f docker-compose.e2e.yml down --volumes && exit 1)
|
||||
@docker compose -f docker-compose.e2e.yml down --volumes
|
||||
@echo "Finished E2E testing successfully."
|
||||
|
||||
e2e-manual:
|
||||
@echo "Starting E2E testing..."
|
||||
@docker compose -f docker-compose.e2e.yml up -d
|
||||
@pnpm playwright test --ui || (docker compose -f docker-compose.e2e.yml down --volumes && exit 1)
|
||||
@docker compose -f docker-compose.e2e.yml down --volumes
|
||||
@echo "Finished E2E testing successfully."
|
||||
@@ -12,7 +12,8 @@
|
||||
|
||||
|
||||
[](LICENSE)
|
||||
[](https://hub.docker.com/r/solucetechnologies/portabase)
|
||||
[](https://hub.docker.com/r/portabase/portabase)
|
||||
[](https://github.com/Portabase/portabase/pkgs/container/charts%2Fportabase)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
[](https://www.buymeacoffee.com/portabase)
|
||||
|
||||
@@ -20,7 +21,11 @@
|
||||
[](https://www.mysql.com/)
|
||||
[](https://mariadb.org/)
|
||||
[](https://sqlite.org/)
|
||||
[](https://redis.io/)
|
||||
[](https://www.mongodb.com/)
|
||||
[](https://valkey.io/)
|
||||
[](https://firebirdsql.org/)
|
||||
|
||||
[](https://github.com/Portabase/portabase)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
|
||||
@@ -53,11 +58,27 @@ You have 4 ways to install Portabase:
|
||||
- Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli)
|
||||
- Docker Run - [details](https://portabase.io/docs/dashboard/setup#docker)
|
||||
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker-compose)
|
||||
- Kubernetes with Helm (soon)
|
||||
- Kubernetes with Helm [details](https://portabase.io/docs/dashboard/setup#helm)
|
||||
- Development setup - [details](https://portabase.io/docs/dashboard/setup#development)
|
||||
|
||||
**Ensure Docker is installed on your machine before getting started.**
|
||||
|
||||
## Supported databases
|
||||
|
||||
| Engine | Support | Supported Versions | Restore |
|
||||
|:-------------------|:-----------|:------------------------------|:--------|
|
||||
| **PostgreSQL** | ✅ Stable | 12, 13, 14, 15, 16, 17 and 18 | Yes |
|
||||
| **MySQL** | ✅ Stable | 5.7, 8 and 9 | Yes |
|
||||
| **MariaDB** | ✅ Stable | 10 and 11 | Yes |
|
||||
| **MongoDB** | ✅ Stable | 4, 5, 6, 7 and 8 | Yes |
|
||||
| **SQLite** | ✅ Stable | 3.x | Yes |
|
||||
| **Redis** | ✅ Stable | 2.8+ | No |
|
||||
| **Valkey** | ✅ Stable | 7.2+ | No |
|
||||
| **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes |
|
||||
| **MSSQL Server** | ❌ Ongoing | - | Yes |
|
||||
|
||||
See the [Database Servers documentation](https://portabase.io/docs/agent/db) for version-specific backup and restore details.
|
||||
|
||||
## Contributors
|
||||
|
||||
[](https://github.com/Portabase/portabase/graphs/contributors)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import React, { Suspense } from "react";
|
||||
import React from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { AuthLogoSection } from "@/components/wrappers/auth/auth-logo-section";
|
||||
import { env } from "@/env.mjs";
|
||||
import { Heart } from "lucide-react";
|
||||
import { ErrorLayout } from "@/components/wrappers/common/error-layout";
|
||||
|
||||
export default async function Layout({
|
||||
children,
|
||||
@@ -31,7 +29,6 @@ export default async function Layout({
|
||||
<Heart className="size-3 fill-red-500 text-red-500 animate-pulse" />{" "}
|
||||
by <span className="font-medium text-foreground">Portabase</span>
|
||||
</p>
|
||||
<p>v{env.NEXT_PUBLIC_PROJECT_VERSION}</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ import Link from "next/link";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { CardAuth } from "@/features/layout/card-auth";
|
||||
import { env } from "@/env.mjs";
|
||||
import { en } from "zod/v4/locales";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Login",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {SettingsTabs} from "@/components/wrappers/dashboard/admin/settings/setti
|
||||
import {desc, isNull} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
@@ -13,6 +14,8 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
where: (fields, {eq}) => eq(fields.name, "system"),
|
||||
});
|
||||
|
||||
console.log(settings)
|
||||
|
||||
const storageChannels = await db.query.storageChannel.findMany({
|
||||
with: {
|
||||
organizations: true
|
||||
@@ -21,7 +24,16 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
orderBy: desc(drizzleDb.schemas.storageChannel.createdAt)
|
||||
}) as StorageChannelWith[]
|
||||
|
||||
if (!settings || !storageChannels ) {
|
||||
const notificationChannels = await db.query.notificationChannel.findMany({
|
||||
with: {
|
||||
organizations: true
|
||||
},
|
||||
where: isNull(drizzleDb.schemas.notificationChannel.organizationId),
|
||||
orderBy: desc(drizzleDb.schemas.notificationChannel.createdAt)
|
||||
}) as NotificationChannelWith[]
|
||||
|
||||
|
||||
if (!settings || !storageChannels || !notificationChannels ) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
@@ -33,7 +45,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
</div>
|
||||
</PageHeader>
|
||||
<PageContent className="flex flex-col gap-5">
|
||||
<SettingsTabs storageChannels={storageChannels} settings={settings} />
|
||||
<SettingsTabs storageChannels={storageChannels} notificationChannels={notificationChannels} settings={settings} />
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
|
||||
+4
@@ -8,6 +8,7 @@ import {getOrganizationProjectDatabases} from "@/lib/services";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
||||
import {DatabaseContent} from "@/components/wrappers/dashboard/projects/database/database-content";
|
||||
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{
|
||||
projectId: string;
|
||||
@@ -83,6 +84,8 @@ export default async function RoutePage(props: PageParams<{
|
||||
notFound();
|
||||
}
|
||||
|
||||
const databaseHealthLogs = dbItem ? await getHealthLast12hLogs({ id: dbItem.id }) : []
|
||||
|
||||
|
||||
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
||||
|
||||
@@ -95,6 +98,7 @@ export default async function RoutePage(props: PageParams<{
|
||||
activeMember={activeMember}
|
||||
settings={settings}
|
||||
database={dbItem}
|
||||
databaseHealthLogs={databaseHealthLogs}
|
||||
isAlreadyRestore={isAlreadyRestore}
|
||||
restorations={restorations}
|
||||
backups={backups}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import {NextResponse} from "next/server";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
const log = logger.child({module: "api/agent/backup/helpers"});
|
||||
|
||||
export function withAgentCheck(handler: Function) {
|
||||
return async (request: Request, context: { params: Promise<{ agentId: string }> }) => {
|
||||
@@ -14,23 +17,22 @@ export function withAgentCheck(handler: Function) {
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json(
|
||||
{ error: "Agent not found" },
|
||||
{ status: 404 }
|
||||
{error: "Agent not found"},
|
||||
{status: 404}
|
||||
);
|
||||
}
|
||||
|
||||
return handler(request, { ...context, agent });
|
||||
return handler(request, {...context, agent});
|
||||
} catch (err) {
|
||||
console.error("Error in agent middleware:", err);
|
||||
log.error({error: err, name: "withAgentCheck"}, "Error in agent middleware");
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
{error: "Internal server error"},
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export async function getDatabaseOrThrow(generatedId: string) {
|
||||
const database = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
|
||||
@@ -43,8 +45,8 @@ export async function getDatabaseOrThrow(generatedId: string) {
|
||||
|
||||
if (!database) {
|
||||
throw NextResponse.json(
|
||||
{ error: "Database associated with generatedId not found" },
|
||||
{ status: 404 }
|
||||
{error: "Database associated with generatedId not found"},
|
||||
{status: 404}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ import {withUpdatedAt} from "@/db/utils";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
||||
import {EventKind} from "@/features/notifications/types";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
const log = logger.child({module: "api/agent/backup/route"});
|
||||
|
||||
export type BodyPost = {
|
||||
method: "manual" | "automatic"
|
||||
@@ -77,7 +80,6 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
return NextResponse.json(
|
||||
@@ -88,7 +90,7 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
{status: 200}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error in POST for INIT backup:", error);
|
||||
log.error({error: error}, "Error in POST for INIT backup");
|
||||
return NextResponse.json(
|
||||
{error: "Internal server error"},
|
||||
{status: 500}
|
||||
@@ -102,7 +104,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
}) => {
|
||||
try {
|
||||
const body: BodyPatch = await request.json();
|
||||
console.log("body", body);
|
||||
log.info({data: body}, "Body from PATH in backup route");
|
||||
|
||||
const status = body.status
|
||||
const backupId = body.backupId
|
||||
@@ -142,9 +144,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
{status: 200}
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
|
||||
console.error("Error in PATCH backup:", error);
|
||||
log.error({error: error}, "Error in PATCH backup")
|
||||
return NextResponse.json(
|
||||
{error: "Internal server error"},
|
||||
{status: 500}
|
||||
|
||||
@@ -5,6 +5,9 @@ import {db} from "@/db";
|
||||
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
const log = logger.child({module: "api/agent/backup/upload/init"});
|
||||
|
||||
export type Body = {
|
||||
generatedId: string
|
||||
@@ -18,7 +21,7 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
try {
|
||||
const body: Body = await request.json();
|
||||
|
||||
console.log("body", body);
|
||||
log.info({data: body}, "Body for backup upload init");
|
||||
|
||||
const generatedId = body.generatedId;
|
||||
const storageChannelId = body.storageChannelId;
|
||||
@@ -66,7 +69,7 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
{status: 200}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error in POST for INIT backup:", error);
|
||||
log.error({error: error}, "Error in POST for INIT backup");
|
||||
return NextResponse.json(
|
||||
{error: "Internal server error"},
|
||||
{status: 500}
|
||||
|
||||
@@ -5,6 +5,9 @@ import {db as dbClient, db} from "@/db";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
const log = logger.child({module: "api/agent/backup/upload/status"});
|
||||
|
||||
export type Body = {
|
||||
generatedId: string
|
||||
@@ -27,8 +30,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
const backupStorageId = body.backupStorageId;
|
||||
const backupId = body.backupId;
|
||||
|
||||
|
||||
console.log("body", body);
|
||||
log.info({data: body}, "Body for backup upload status");
|
||||
|
||||
const database = await getDatabaseOrThrow(generatedId);
|
||||
|
||||
@@ -85,7 +87,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
{status: 200}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error in POST for INIT backup:", error);
|
||||
log.error({error: error},"Error in POST for INIT backup");
|
||||
return NextResponse.json(
|
||||
{error: "Internal server error"},
|
||||
{status: 500}
|
||||
|
||||
@@ -4,7 +4,9 @@ import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
const log = logger.child({module: "api/agent/restore"});
|
||||
|
||||
export type BodyResultRestore = {
|
||||
generatedId: string
|
||||
@@ -66,14 +68,13 @@ export async function POST(
|
||||
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
|
||||
|
||||
const response = {
|
||||
message: true,
|
||||
details: "Restoration successfully updated"
|
||||
status: true,
|
||||
message: "Restoration successfully updated"
|
||||
}
|
||||
|
||||
|
||||
return Response.json(response, {status: 200})
|
||||
} catch (error) {
|
||||
console.error('Error in POST handler:', error);
|
||||
log.error({error: error}, "Error in POST handler")
|
||||
return NextResponse.json(
|
||||
{error: 'Internal server error'},
|
||||
{status: 500}
|
||||
|
||||
@@ -2,7 +2,7 @@ import {NextResponse} from "next/server";
|
||||
import {Body} from "./route";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {Agent} from "@/db/schema/08_agent";
|
||||
import {Database, DatabaseWith} from "@/db/schema/07_database";
|
||||
import {DatabaseWith} from "@/db/schema/07_database";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db, db as dbClient} from "@/db";
|
||||
import {and, eq, inArray} from "drizzle-orm";
|
||||
@@ -11,6 +11,9 @@ import {withUpdatedAt} from "@/db/utils";
|
||||
import type {StorageInput} from "@/features/storages/types";
|
||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
const log = logger.child({module: "api/agent/status/helpers"});
|
||||
|
||||
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date, settings: Setting) {
|
||||
const databasesResponse = [];
|
||||
@@ -56,7 +59,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
}
|
||||
|
||||
if (!dbmsEnumSchema.safeParse(db.dbms).success) {
|
||||
console.log(`Database type not available: ${db.dbms}`);
|
||||
log.error({name: "handleDatabases"},`Database type not available: ${db.dbms}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -67,12 +70,24 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
name: db.name,
|
||||
dbms: db.dbms as EDbmsSchema,
|
||||
agentDatabaseId: db.generatedId,
|
||||
lastContact: lastContact,
|
||||
lastContact: db.pingStatus ? lastContact : null,
|
||||
healthErrorCount: null
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
if (databaseCreated) {
|
||||
|
||||
|
||||
await dbClient
|
||||
.insert(drizzleDb.schemas.healthcheckLog)
|
||||
.values({
|
||||
kind: "database",
|
||||
status: db.pingStatus ? "success" : "failed",
|
||||
objectId: databaseCreated.id,
|
||||
date: lastContact
|
||||
})
|
||||
|
||||
const storages = await getDatabaseStorageChannels(databaseCreated.id)
|
||||
|
||||
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null));
|
||||
@@ -84,11 +99,24 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
.set(withUpdatedAt({
|
||||
name: db.name,
|
||||
agentId: agent.id,
|
||||
lastContact: lastContact
|
||||
dbms: db.dbms as EDbmsSchema,
|
||||
lastContact: db.pingStatus ? lastContact : existingDatabase.lastContact,
|
||||
healthErrorCount: db.pingStatus ? null : existingDatabase.healthErrorCount,
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
|
||||
.returning();
|
||||
|
||||
|
||||
await dbClient
|
||||
.insert(drizzleDb.schemas.healthcheckLog)
|
||||
.values({
|
||||
kind: "database",
|
||||
status: db.pingStatus ? "success" : "failed",
|
||||
objectId: databaseUpdated.id,
|
||||
date: lastContact
|
||||
})
|
||||
|
||||
|
||||
const activeBackup = await dbClient.query.backup.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
|
||||
@@ -159,11 +187,11 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
|
||||
const errorMessage = "Failed to get backup URL";
|
||||
console.error("Restoration failed: ", errorMessage);
|
||||
log.error({error: errorMessage, name: "handleDatabases"}, "Restoration failed");
|
||||
continue;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Restoration crashed unexpectedly:", err);
|
||||
log.error({error: err, name: "handleDatabases"}, "Restoration crashed unexpectedly");
|
||||
await dbClient
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
.set({status: "failed"})
|
||||
@@ -180,7 +208,6 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta));
|
||||
}
|
||||
}
|
||||
|
||||
return databasesResponse;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,17 @@ import {EDbmsSchema} from "@/db/schema/types";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
import {notFound} from "next/navigation";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
|
||||
const log = logger.child({module: "api/agent/status/route"});
|
||||
|
||||
|
||||
export type databaseAgent = {
|
||||
name: string,
|
||||
dbms: EDbmsSchema,
|
||||
generatedId: string
|
||||
pingStatus: boolean
|
||||
}
|
||||
|
||||
export type Body = {
|
||||
@@ -27,14 +31,14 @@ export async function POST(
|
||||
) {
|
||||
try {
|
||||
const agentId = (await params).agentId
|
||||
console.log(agentId)
|
||||
log.info(`Agent ID: ${agentId}`)
|
||||
const body: Body = await request.json();
|
||||
const lastContact = new Date();
|
||||
let message: string
|
||||
|
||||
if (!isUuidv4(agentId)) {
|
||||
message = "agentId is not a valid uuid"
|
||||
console.error(message)
|
||||
log.error({error: message}, "An error occurred")
|
||||
return NextResponse.json(
|
||||
{error: "agentId is not a valid uuid"},
|
||||
{status: 500}
|
||||
@@ -55,17 +59,25 @@ export async function POST(
|
||||
return NextResponse.json({error: "An error occured"}, {status: 404})
|
||||
}
|
||||
|
||||
|
||||
const databasesResponse = await handleDatabases(body, agent, lastContact, settings)
|
||||
|
||||
await db
|
||||
.update(drizzleDb.schemas.agent)
|
||||
.set(withUpdatedAt({
|
||||
version: body.version,
|
||||
lastContact: lastContact
|
||||
lastContact: lastContact,
|
||||
healthErrorCount: null
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.agent.id, agentId));
|
||||
|
||||
await db
|
||||
.insert(drizzleDb.schemas.healthcheckLog)
|
||||
.values({
|
||||
kind: "agent",
|
||||
status: "success",
|
||||
objectId: agentId,
|
||||
date: lastContact
|
||||
})
|
||||
|
||||
const response = {
|
||||
agent: {
|
||||
@@ -75,10 +87,9 @@ export async function POST(
|
||||
databases: databasesResponse
|
||||
}
|
||||
|
||||
|
||||
return Response.json(response)
|
||||
} catch (error) {
|
||||
console.error('Error in POST handler:', error);
|
||||
log.error({error: error}, "Error in POST handler")
|
||||
return NextResponse.json(
|
||||
{error: 'Internal server error'},
|
||||
{status: 500}
|
||||
|
||||
@@ -2,6 +2,9 @@ import {auth} from "@/lib/auth/auth";
|
||||
import {headers} from "next/headers";
|
||||
import {NextResponse} from "next/server";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
const log = logger.child({module: "api/events"});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
|
||||
@@ -16,9 +19,9 @@ export async function GET(request: Request) {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
console.log('Stream started');
|
||||
log.info("Stream started");
|
||||
const handleModification = (data: any) => {
|
||||
console.log('Modification event triggered:', data);
|
||||
log.info({data: data},"Modification event triggered");
|
||||
controller.enqueue(`event: modification\n`);
|
||||
controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
@@ -26,7 +29,7 @@ export async function GET(request: Request) {
|
||||
eventEmitter.on('modification', handleModification);
|
||||
|
||||
request.signal.addEventListener('abort', () => {
|
||||
console.log('Client disconnected');
|
||||
log.info("Client disconnected");
|
||||
controller.close();
|
||||
eventEmitter.off('modification', handleModification);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,9 @@ import path from "path";
|
||||
import type {StorageInput} from "@/features/storages/types";
|
||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
||||
import {Readable} from "node:stream";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
const log = logger.child({module: "api/files/backups"});
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
@@ -30,7 +33,7 @@ export async function GET(
|
||||
}
|
||||
};
|
||||
|
||||
console.debug(input);
|
||||
log.info({input: input}, "Dispatch Storage");
|
||||
|
||||
const result = await dispatchStorage(input, undefined, storageId);
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
import {headers} from "next/headers";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {StorageInput} from "@/features/storages/types";
|
||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
||||
import {Readable} from "node:stream";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
const log = logger.child({module: "api/files/images"});
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
@@ -56,7 +56,7 @@ export async function GET(
|
||||
const result = await dispatchStorage(input, undefined, storageId);
|
||||
|
||||
if (!result.file || !(result.file instanceof Readable)) {
|
||||
console.error(`An error occurred while getting file :`, result);
|
||||
log.error({error: result}, `An error occurred while getting file`);
|
||||
return NextResponse.json(
|
||||
{error: "Invalid file payload"},
|
||||
{status: 500}
|
||||
@@ -82,7 +82,7 @@ export async function GET(
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error streaming image:", err);
|
||||
log.error({error: err}, `Error streaming image`);
|
||||
return NextResponse.json({error: "Error fetching file"}, {status: 500});
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import {NextResponse} from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {env} from "@/env.mjs";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
const log = logger.child({module: "api/tus/hooks"});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
@@ -13,7 +15,7 @@ export async function POST(request: Request) {
|
||||
const uploadOffset = headers["Upload-Offset"]?.[0];
|
||||
const status = headers["X-Status"]?.[0];
|
||||
|
||||
console.log(`Upload ID : ${event.Upload.ID} (${uploadOffset}/${uploadLength})`);
|
||||
log.info(`Upload ID : ${event.Upload.ID} (${uploadOffset}/${uploadLength})`);
|
||||
|
||||
if (status === "success") {
|
||||
if (
|
||||
@@ -29,9 +31,7 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({error: "Missing X-File-Path"}, {status: 500});
|
||||
}
|
||||
|
||||
|
||||
// const uploadDir = path.join(process.cwd(), "/private/uploads/");
|
||||
const uploadDir = path.join(env.PRIVATE_PATH, "/uploads/");
|
||||
const uploadDir = path.join(env.PRIVATE_PATH!, "/uploads/");
|
||||
|
||||
const oldFilePath = path.join(uploadDir, "tmp", id);
|
||||
const newFilePath = path.join(uploadDir, filePath);
|
||||
@@ -74,7 +74,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
return NextResponse.json({});
|
||||
} catch (error) {
|
||||
console.error("Hook error:", error);
|
||||
log.error({error: error},"TUS Hook error");
|
||||
return NextResponse.json({error: "Internal server error"}, {status: 500});
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { author, geistMono, poppins } from "@/fonts/fonts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
const title = process.env.PROJECT_NAME ?? "App Title";
|
||||
const title = process.env.PROJECT_NAME ?? "Portabase";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/dockerfile/Dockerfile
|
||||
target: prod
|
||||
ports:
|
||||
- '8887:80'
|
||||
environment:
|
||||
TZ: "Europe/Paris"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- portabase-e2e-data:/data
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: postgres:18-alpine
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres-e2e-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_DB=devdb
|
||||
- POSTGRES_USER=devuser
|
||||
- POSTGRES_PASSWORD=changeme
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
portabase-e2e-data:
|
||||
postgres-e2e-data:
|
||||
@@ -4,7 +4,7 @@ services:
|
||||
# context: .
|
||||
# dockerfile: docker/dockerfile/Dockerfile
|
||||
# target: prod
|
||||
image: portabase/portabase:1.4.0
|
||||
image: portabase/portabase:1.7.1
|
||||
ports:
|
||||
- '8887:80'
|
||||
environment:
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {users} from "./helpers/auth";
|
||||
import {changeUserRole, create, switchToDefault} from "./helpers/access-management";
|
||||
import {LOCAL_STORAGE_PATH} from "./helpers/session";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const firstOrganization = "Organization A";
|
||||
const secondOrganization = "Organization B";
|
||||
|
||||
test.describe.serial(() => {
|
||||
test("Create an organization from organizations page", async ({page}) => {
|
||||
await page.goto("/dashboard/admin/organizations");
|
||||
await expect(page.getByRole("heading", {name: "Active organizations"})).toBeVisible();
|
||||
|
||||
await create(page, "button", firstOrganization);
|
||||
|
||||
await expect(page.getByText(/Organization has been successfully created\./i)).toBeVisible();
|
||||
await expect(page).toHaveURL("/dashboard/admin/organizations");
|
||||
await expect(page.getByRole("button", {name: firstOrganization, exact: true})).toBeVisible();
|
||||
|
||||
await switchToDefault(page);
|
||||
});
|
||||
|
||||
test("Create an organization from sidebar button", async ({page}) => {
|
||||
await page.goto("/dashboard/home");
|
||||
await expect(page.getByRole("heading", {name: "Dashboard"})).toBeVisible();
|
||||
|
||||
await create(page, "sidebar", secondOrganization);
|
||||
|
||||
await expect(page.getByText(/Organization has been successfully created\./i)).toBeVisible();
|
||||
await expect(page).toHaveURL("/dashboard/home");
|
||||
await expect(page.getByRole("button", {name: secondOrganization, exact: true})).toBeVisible();
|
||||
|
||||
await switchToDefault(page);
|
||||
});
|
||||
|
||||
test("Change John Doe's role from pending to user", async ({page}) => {
|
||||
await page.goto("/dashboard/admin/users");
|
||||
await expect(page.getByText(users.normal.email, {exact: true})).toBeVisible();
|
||||
|
||||
const userRow = page.locator("tr").filter({hasText: users.normal.email}).first();
|
||||
await expect(userRow).toBeVisible();
|
||||
await userRow.locator("button").last().click();
|
||||
await page.getByRole('menuitem', {name: 'Role'}).click();
|
||||
|
||||
await expect(page.getByRole("heading", {name: "Change the user's role"})).toBeVisible();
|
||||
|
||||
await changeUserRole(page, "user")
|
||||
|
||||
await expect(page.getByText("User role changed successfully.")).toBeVisible();
|
||||
await expect(page.locator("tr").filter({hasText: users.normal.email}).getByText("user", {exact: true})).toBeVisible();
|
||||
});
|
||||
|
||||
test("Add John Doe to Organization A", async ({page}) => {
|
||||
await page.goto("/dashboard/admin/organizations");
|
||||
await expect(page.getByRole("heading", {name: "Active organizations"})).toBeVisible();
|
||||
|
||||
const organizationRow = page.locator("tr").filter({hasText: firstOrganization}).first();
|
||||
await expect(organizationRow).toBeVisible();
|
||||
await organizationRow.locator('a[href*="/dashboard/admin/organizations/"], a[href*="organizations/"]').click();
|
||||
|
||||
await expect(page.getByText(firstOrganization, {exact: true})).toBeVisible();
|
||||
await page.getByRole("button", {name: /Add member/i}).click();
|
||||
await expect(page.getByRole("heading", {name: "Add member to your organization"})).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder("Enter a user email").fill(users.normal.email);
|
||||
await page.getByRole("option", {name: new RegExp(users.normal.email, "i")}).click();
|
||||
await page.getByRole("button", {name: "Confirm"}).click();
|
||||
|
||||
await expect(page.getByText("Member successfully added!")).toBeVisible();
|
||||
await expect(page.getByText(users.normal.email, {exact: true})).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {execSync} from "child_process";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import {createAgentWithDockerDatabases} from "./helpers/agent-cli";
|
||||
import {create, edit, get, remove} from "./helpers/agent";
|
||||
import {LOCAL_STORAGE_PATH} from "./helpers/session";
|
||||
|
||||
const agent = {
|
||||
aName: "Agent A",
|
||||
aUpdatedName: "Agent A Updated",
|
||||
bName: "Agent B",
|
||||
description: "Agent created by Playwright E2E",
|
||||
updatedDescription: "Agent updated by Playwright E2E",
|
||||
};
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
test.describe.serial(() => {
|
||||
let agentWorkspace: string | null = null;
|
||||
|
||||
test("Create agent A from empty state", async ({page}) => {
|
||||
await page.goto("/dashboard/agents");
|
||||
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
|
||||
await expect(page.getByText("Create new Agent", {exact: true})).toBeVisible();
|
||||
await create(page, "emptyState", agent.aName, agent.description);
|
||||
|
||||
await expect(page.getByText("Success creating agent")).toBeVisible();
|
||||
await expect(get(page, agent.aName)).toBeVisible();
|
||||
await expect(page.getByText("Create new Agent", {exact: true})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Edit agent A", async ({page}) => {
|
||||
await page.goto("/dashboard/agents");
|
||||
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
|
||||
await expect(get(page, agent.aName)).toBeVisible();
|
||||
|
||||
await edit(page, agent.aName, agent.aUpdatedName, agent.updatedDescription);
|
||||
|
||||
await expect(page.getByText("Success updating agent")).toBeVisible();
|
||||
await expect(page.getByText(agent.aUpdatedName, {exact: true})).toBeVisible();
|
||||
await expect(page.getByText(agent.updatedDescription, {exact: true})).toBeVisible();
|
||||
});
|
||||
|
||||
test("Create agent B from classic button", async ({page}) => {
|
||||
await page.goto("/dashboard/agents");
|
||||
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
|
||||
await expect(page.getByRole("button", {name: /Create Agent/i})).toBeVisible();
|
||||
await create(page, "button", agent.bName, agent.description);
|
||||
|
||||
await expect(page.getByText("Success creating agent")).toBeVisible();
|
||||
await expect(get(page, agent.bName)).toBeVisible();
|
||||
});
|
||||
|
||||
test("Delete Agent B", async ({page}) => {
|
||||
await page.goto("/dashboard/agents");
|
||||
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
|
||||
await expect(get(page, agent.bName)).toBeVisible();
|
||||
await remove(page, agent.bName);
|
||||
|
||||
await expect(page.getByText("Agent has been successfully deleted.")).toBeVisible();
|
||||
await expect(page).toHaveURL("/dashboard/agents");
|
||||
await expect(page.getByText(agent.bName)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// test("Launch the updated agent", async ({page}) => {
|
||||
// await page.goto("/dashboard/agents");
|
||||
// await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
|
||||
// await get(page, agent.aName).click();
|
||||
//
|
||||
// await expect(page).toHaveURL(/\/dashboard\/agents\/.+/);
|
||||
// await expect(page.getByText(agent.aName, {exact: true})).toBeVisible();
|
||||
// await expect(page.getByText("Registration & Setup")).toBeVisible();
|
||||
//
|
||||
// const commandInput = page.locator("input[readonly]").first();
|
||||
// await page.locator("input[readonly]").first().locator("xpath=following-sibling::button[1]").click();
|
||||
// const command = await commandInput.inputValue();
|
||||
//
|
||||
// agentWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "portabase-agent-"));
|
||||
// await createAgentWithDockerDatabases(command, agentWorkspace);
|
||||
// execSync(`portabase start "${agent.aName}"`, {
|
||||
// cwd: agentWorkspace,
|
||||
// stdio: "pipe",
|
||||
// timeout: 120_000,
|
||||
// });
|
||||
//
|
||||
// await expect(page.getByText("Never connected.")).toHaveCount(0, {timeout: 120_000});
|
||||
// await expect(page.getByText("Action Required")).toHaveCount(0);
|
||||
// });
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (agentWorkspace) {
|
||||
try {
|
||||
execSync(`portabase stop "${agent.aName}"`, {
|
||||
cwd: agentWorkspace,
|
||||
stdio: "pipe",
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`portabase uninstall --force "${agent.aName}"`, {
|
||||
cwd: agentWorkspace,
|
||||
stdio: "pipe",
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
|
||||
fs.rmSync(agentWorkspace, {recursive: true, force: true});
|
||||
agentWorkspace = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, register, users} from "./helpers/auth";
|
||||
import {LOCAL_STORAGE_PATH} from "./helpers/session";
|
||||
|
||||
const TIMEOUT = undefined
|
||||
// const TIMEOUT = 5000
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH})
|
||||
|
||||
test.describe.serial( () => {
|
||||
|
||||
test('Redirect to login if not connected', async ({page}) => {
|
||||
await page.goto('/dashboard/projects');
|
||||
await expect(page).toHaveURL("login?redirect=%2Fdashboard%2Fprojects", {timeout: TIMEOUT});
|
||||
});
|
||||
|
||||
test('Password too short', async ({page}) => {
|
||||
await page.goto('')
|
||||
await page.click('text=Sign up')
|
||||
await expect(page).toHaveURL('/register')
|
||||
|
||||
let password = '123456'
|
||||
await register(page, users["admin"].username, users["admin"].email, password, password)
|
||||
|
||||
const toast = page.locator('text=Must have at least 8 character')
|
||||
await expect(toast).toBeVisible()
|
||||
})
|
||||
|
||||
test('Password too simple', async ({page}) => {
|
||||
await page.goto('')
|
||||
await page.click('text=Sign up')
|
||||
await expect(page).toHaveURL('/register')
|
||||
|
||||
let password = '12345678'
|
||||
await register(page, users["admin"].username, users["admin"].email, password, password)
|
||||
|
||||
const toast = page.locator('text=Your password must contain at least one uppercase letter, one lowercase letter, one number, and one special character.')
|
||||
await expect(toast).toBeVisible()
|
||||
})
|
||||
|
||||
test('Password and confirm password mismatch', async ({page}) => {
|
||||
await page.goto('/')
|
||||
await page.click('text=Sign up')
|
||||
await expect(page).toHaveURL('/register')
|
||||
|
||||
let password = 'testPASS123456!'
|
||||
let confirmPassword = 'testPASS123456!!'
|
||||
await register(page, users["admin"].username, users["admin"].email, password, confirmPassword)
|
||||
|
||||
const toast = page.locator('text=The passwords did not match')
|
||||
await expect(toast).toBeVisible()
|
||||
})
|
||||
|
||||
test('Successful register for admin', async ({page}) => {
|
||||
await page.goto('/')
|
||||
|
||||
await page.click('text=Sign up')
|
||||
await expect(page).toHaveURL('/register')
|
||||
|
||||
await register(page, users["admin"].username, users["admin"].email, users["admin"].password, users["admin"].password)
|
||||
|
||||
await expect(page).toHaveURL('/login', {timeout: TIMEOUT})
|
||||
})
|
||||
|
||||
test('User already exists.', async ({page}) => {
|
||||
await page.goto('/')
|
||||
|
||||
await page.click('text=Sign up')
|
||||
await expect(page).toHaveURL('/register')
|
||||
|
||||
await register(page, users["admin"].username, users["admin"].email, users["admin"].password, users["admin"].password)
|
||||
|
||||
const toast = page.locator('text=User already exists. Use another email.')
|
||||
await expect(toast).toBeVisible()
|
||||
})
|
||||
|
||||
test('Successful register for normal', async ({page}) => {
|
||||
await page.goto('/')
|
||||
|
||||
await page.click('text=Sign up')
|
||||
await expect(page).toHaveURL('/register')
|
||||
|
||||
await register(page, users["normal"].username, users["normal"].email, users["normal"].password, users["normal"].password)
|
||||
|
||||
await expect(page).toHaveURL('/login', {timeout: TIMEOUT})
|
||||
})
|
||||
|
||||
test('Failed login because account not active', async ({page}) => {
|
||||
await page.goto('/login')
|
||||
await login(page, users["normal"].email, users["normal"].password)
|
||||
|
||||
const toast = page.locator('text=Your account is not active.')
|
||||
await expect(toast).toBeVisible()
|
||||
})
|
||||
|
||||
test('Successful login', async ({page}) => {
|
||||
await page.goto('/login')
|
||||
await login(page, users["admin"].email, users["admin"].password)
|
||||
|
||||
await expect(page).toHaveURL('/dashboard/home', {timeout: TIMEOUT})
|
||||
await expect(page.getByRole('link', {name: 'Logo Portabase'})).toBeVisible()
|
||||
await page.context().storageState({path: LOCAL_STORAGE_PATH})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import fs from "fs";
|
||||
import {logout} from "./helpers/auth";
|
||||
import {LOCAL_STORAGE_PATH} from "./helpers/session";
|
||||
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
test.describe( () => {
|
||||
test.afterAll(async () => {
|
||||
if (fs.existsSync(LOCAL_STORAGE_PATH)) {
|
||||
fs.unlinkSync(LOCAL_STORAGE_PATH);
|
||||
}
|
||||
});
|
||||
|
||||
test("Remove shared storage state", async ({page}) => {
|
||||
await test.step("Logout shared authenticated session", async () => {
|
||||
if (!fs.existsSync(LOCAL_STORAGE_PATH)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(LOCAL_STORAGE_PATH, "utf-8").trim();
|
||||
if (!content || content === "{}") {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.goto('/dashboard/home');
|
||||
await logout(page);
|
||||
await expect(page).toHaveURL(/\/login(?:\?.*)?$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import {Page} from "@playwright/test";
|
||||
|
||||
export function getUserRow(page: Page, email: string) {
|
||||
return page.locator("tr").filter({hasText: email}).first();
|
||||
}
|
||||
|
||||
export function getOrganizationRow(page: Page, organizationName: string) {
|
||||
return page.locator("tr").filter({hasText: organizationName}).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an organization from the selected entrypoint.
|
||||
*
|
||||
* Available entrypoints:
|
||||
* - `button`: the organizations page create button.
|
||||
* - `sidebar`: the sidebar organization switcher.
|
||||
*
|
||||
* Executes from:
|
||||
* - `/dashboard/admin/organizations` for "button" entrypoint
|
||||
* - any `/dashboard/**` page for "sidebar" entrypoint
|
||||
*/
|
||||
export async function create(page: Page, entrypoint: "button" | "sidebar", name: string) {
|
||||
if (entrypoint === "sidebar") {
|
||||
await page.getByRole("button", {name: "Default Organization"}).click();
|
||||
await page.getByText("Create organization", {exact: true}).click();
|
||||
} else {
|
||||
await page.getByRole("button", {name: /Create a new organization/i}).click();
|
||||
}
|
||||
|
||||
await page.getByLabel("Name").fill(name);
|
||||
await page.getByRole("button", {name: "Create"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the active organization from the sidebar switcher.
|
||||
*
|
||||
* Executes from: any `/dashboard/**` if the sidebar is visible.
|
||||
*/
|
||||
export async function switchTo(page: Page, name: string) {
|
||||
await page.getByTestId('organization-dropdown').click();
|
||||
await page.getByRole('menuitem', {name: name}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch back to the default organization.
|
||||
*
|
||||
* Executes from: any `/dashboard/**` if the sidebar is visible.
|
||||
*/
|
||||
export async function switchToDefault(page: Page) {
|
||||
await switchTo(page, "Default Organization");
|
||||
}
|
||||
|
||||
const ROLE_LABELS = {
|
||||
pending: "Pending",
|
||||
user: "User",
|
||||
admin: "Admin",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Change the global role of a user from the admin users role modal.
|
||||
*
|
||||
* Executes from: the "Change the user's role" dialog opened from `/dashboard/admin/users`.
|
||||
*/
|
||||
export async function changeUserRole(page: Page, role: keyof typeof ROLE_LABELS) {
|
||||
const roleOption = ROLE_LABELS[role];
|
||||
|
||||
await page.getByRole("combobox").click();
|
||||
await page.getByRole("option", {name: roleOption}).click();
|
||||
await page.getByRole("button", {name: "Validate"}).click();
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import * as pty from "node-pty";
|
||||
|
||||
type InteractiveStep = {
|
||||
match: RegExp;
|
||||
reply: string;
|
||||
};
|
||||
|
||||
function normalizeOutput(output: string) {
|
||||
return output.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an interactive CLI command and answer prompts in sequence.
|
||||
* Entry point:
|
||||
* - `command` is the full CLI command executed in the PTY.
|
||||
* - `cwd` is the local workspace where the command is launched.
|
||||
* - `steps` defines which prompt text is matched and which reply is sent.
|
||||
* - `timeoutMs` controls when the command is aborted if it stalls.
|
||||
* Executes from: not page-bound; runs from a local test workspace on the Node.js side.
|
||||
*/
|
||||
export async function runInteractiveCommand(
|
||||
command: string,
|
||||
cwd: string,
|
||||
steps: InteractiveStep[],
|
||||
timeoutMs: number = 120_000,
|
||||
) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = pty.spawn("sh", ["-lc", command], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: "xterm-256color",
|
||||
},
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
});
|
||||
|
||||
let currentStep = 0;
|
||||
let output = "";
|
||||
const timer = setTimeout(() => {
|
||||
child.kill();
|
||||
reject(new Error(`Interactive command timed out.\n\nCommand: ${command}\n\nOutput:\n${normalizeOutput(output)}`));
|
||||
}, timeoutMs);
|
||||
|
||||
const handleChunk = (chunk: string) => {
|
||||
output += chunk;
|
||||
const normalizedOutput = normalizeOutput(output);
|
||||
|
||||
while (currentStep < steps.length && steps[currentStep].match.test(normalizedOutput)) {
|
||||
child.write(steps[currentStep].reply);
|
||||
currentStep += 1;
|
||||
}
|
||||
};
|
||||
|
||||
child.onData(handleChunk);
|
||||
child.onExit(({exitCode}) => {
|
||||
clearTimeout(timer);
|
||||
if (exitCode === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`Interactive command failed with exit code ${exitCode}.\n\nCommand: ${command}\n\nOutput:\n${normalizeOutput(output)}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and configure a Portabase agent with two Docker-backed databases.
|
||||
* Entry point:
|
||||
* - `command` is the CLI setup command copied from the agent details page.
|
||||
* - `cwd` is the local workspace where the agent is installed.
|
||||
* - this helper answers the wizard for PostgreSQL first, then MariaDB.
|
||||
* Executes from: not page-bound; runs from a local test workspace on the Node.js side.
|
||||
*/
|
||||
export async function createAgentWithDockerDatabases(command: string, cwd: string) {
|
||||
await runInteractiveCommand(command, cwd, [
|
||||
{
|
||||
match: /configure.*database|add.*database/i,
|
||||
reply: "y\n",
|
||||
},
|
||||
{
|
||||
match: /docker.*new local container|manual.*external|external.*existing/i,
|
||||
reply: "\r",
|
||||
},
|
||||
{
|
||||
match: /postgresql|mariadb/i,
|
||||
reply: "\r",
|
||||
},
|
||||
{
|
||||
match: /another.*database|add.*another|configure.*another/i,
|
||||
reply: "y\n",
|
||||
},
|
||||
{
|
||||
match: /docker.*new local container|manual.*external|external.*existing/i,
|
||||
reply: "\r",
|
||||
},
|
||||
{
|
||||
match: /postgresql|mariadb/i,
|
||||
reply: "\u001B[B\r",
|
||||
},
|
||||
{
|
||||
match: /another.*database|add.*another|configure.*another/i,
|
||||
reply: "n\n",
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {Page} from "@playwright/test";
|
||||
|
||||
|
||||
/**
|
||||
* Locate an agent card in the list.
|
||||
|
||||
* Executes from: `/dashboard/agents`.
|
||||
*/
|
||||
export function get(page: Page, name: string) {
|
||||
return page.locator('a[href^="/dashboard/agents"]').filter({hasText: name}).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an agent from the selected entrypoint.
|
||||
*
|
||||
* Available entrypoints:
|
||||
* - `button`: the classic create button.
|
||||
* - `emptyState`: the empty-state CTA.
|
||||
* - `auto`: uses the classic create button and falls back to the empty-state CTA.
|
||||
*
|
||||
* Executes from: `/dashboard/agents`.
|
||||
*/
|
||||
export async function create(page: Page, entrypoint: "auto" | "emptyState" | "button" = "auto", agentName: string, description: string) {
|
||||
if (entrypoint === "auto") {
|
||||
const createButton = page.getByRole("button", {name: /Create Agent/i});
|
||||
if (await createButton.isVisible()) await page.getByRole("button", {name: /Create Agent/i}).click();
|
||||
await page.getByText("Create new Agent", {exact: true}).click();
|
||||
} else if (entrypoint === "button") {
|
||||
await page.getByRole("button", {name: /Create Agent/i}).click();
|
||||
} else if (entrypoint === "emptyState") {
|
||||
await page.getByText("Create new Agent", {exact: true}).click();
|
||||
}
|
||||
|
||||
await page.getByLabel("Name").fill(agentName);
|
||||
await page.getByLabel("Description").fill(description);
|
||||
await page.getByRole("button", {name: "Create"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an existing agent (name and description) from its details page.
|
||||
*
|
||||
* Executes from: `/dashboard/agents/[agentId]`.
|
||||
*/
|
||||
export async function edit(page: Page, currentName: string, updatedName: string, updatedDescription: string) {
|
||||
await get(page, currentName).click();
|
||||
|
||||
await page
|
||||
.getByRole("button", {name: /Delete Agent/i})
|
||||
.locator("xpath=ancestor::div[1]/preceding-sibling::div[1]/*[1]")
|
||||
.click();
|
||||
|
||||
await page.getByLabel("Name").fill(updatedName);
|
||||
await page.getByLabel("Description").fill(updatedDescription);
|
||||
await page.getByRole("button", {name: "Update"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an agent from its details page.
|
||||
|
||||
* Executes from: `/dashboard/agents/[agentId]`.
|
||||
*/
|
||||
export async function remove(page: Page, name: string) {
|
||||
await get(page, name).click();
|
||||
await page.getByRole("button", {name: /Delete Agent/i}).click();
|
||||
await page.getByRole("button", {name: "Delete", exact: true}).click();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {Page} from "@playwright/test";
|
||||
|
||||
export type UserCredentials = {
|
||||
username: string
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export const users: Record<string, UserCredentials> = {
|
||||
admin: {username: "Admin", email: "admin@example.com", password: "testPASS123456!"},
|
||||
normal: {username: "John Doe", email: "john.doe@example.com", password: "testPASS123456!"},
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill and submit the registration form.
|
||||
*
|
||||
* Executes from: `/register`.
|
||||
*/
|
||||
export async function register(page: Page, name: string, email: string, password: string, confirmPassword: string) {
|
||||
await page.locator('input[name="name"]').fill(name)
|
||||
await page.locator('input[name="email"]').fill(email)
|
||||
await page.locator('input[name="password"]').fill(password)
|
||||
await page.locator('input[name="confirmPassword"]').fill(confirmPassword)
|
||||
|
||||
await page.click('button[type="submit"]')
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill and submit the login form.
|
||||
*
|
||||
* Executes from: `/login`.
|
||||
*/
|
||||
export async function login(page: Page, email: string, password: string) {
|
||||
await page.locator('input[name="email"]').fill(email)
|
||||
await page.locator('input[name="password"]').fill(password)
|
||||
|
||||
await page.locator('button:has-text("Login")').click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out the current authenticated user.
|
||||
*
|
||||
* Executes from: any authenticated `/dashboard/**` page.
|
||||
*/
|
||||
export async function logout(page: Page) {
|
||||
const profileButton = page.getByTestId('profile-dropdown')
|
||||
await profileButton.first().click();
|
||||
|
||||
await page.getByRole("menuitem").filter({hasText: /Logout/i}).click();
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
const REQUIRED_E2E_ENV_VARS = [
|
||||
"E2E_NOTIFICATION_SMTP_HOST",
|
||||
"E2E_NOTIFICATION_SMTP_PORT",
|
||||
"E2E_NOTIFICATION_SMTP_USER",
|
||||
"E2E_NOTIFICATION_SMTP_PASSWORD",
|
||||
"E2E_NOTIFICATION_SMTP_FROM",
|
||||
"E2E_NOTIFICATION_SMTP_TO",
|
||||
"E2E_NOTIFICATION_SLACK_WEBHOOK",
|
||||
"E2E_NOTIFICATION_DISCORD_WEBHOOK",
|
||||
"E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN",
|
||||
"E2E_NOTIFICATION_TELEGRAM_CHAT_ID",
|
||||
"E2E_NOTIFICATION_TELEGRAM_TOPIC_ID",
|
||||
"E2E_NOTIFICATION_GOTIFY_SERVER_URL",
|
||||
"E2E_NOTIFICATION_GOTIFY_APP_TOKEN",
|
||||
"E2E_NOTIFICATION_NTFY_TOPIC",
|
||||
"E2E_NOTIFICATION_NTFY_SERVER_URL",
|
||||
"E2E_NOTIFICATION_NTFY_TOKEN",
|
||||
"E2E_NOTIFICATION_NTFY_USERNAME",
|
||||
"E2E_NOTIFICATION_NTFY_PASSWORD",
|
||||
"E2E_NOTIFICATION_WEBHOOK_URL",
|
||||
"E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER",
|
||||
"E2E_NOTIFICATION_WEBHOOK_SECRET",
|
||||
"E2E_STORAGE_AWS_S3_ENDPOINT_URL",
|
||||
"E2E_STORAGE_AWS_S3_REGION",
|
||||
"E2E_STORAGE_AWS_S3_ACCESS_KEY",
|
||||
"E2E_STORAGE_AWS_S3_SECRET_KEY",
|
||||
"E2E_STORAGE_AWS_S3_BUCKET_NAME",
|
||||
"E2E_STORAGE_AWS_S3_PORT",
|
||||
"E2E_STORAGE_R2_ENDPOINT_URL",
|
||||
"E2E_STORAGE_R2_REGION",
|
||||
"E2E_STORAGE_R2_ACCESS_KEY",
|
||||
"E2E_STORAGE_R2_SECRET_KEY",
|
||||
"E2E_STORAGE_R2_BUCKET_NAME",
|
||||
"E2E_STORAGE_R2_PORT",
|
||||
"E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID",
|
||||
"E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET",
|
||||
"E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID",
|
||||
] as const;
|
||||
|
||||
function assertRequiredEnvVars() {
|
||||
const missingVars = REQUIRED_E2E_ENV_VARS.filter((name) => {
|
||||
const value = process.env[name]?.trim();
|
||||
return !value;
|
||||
});
|
||||
|
||||
if (missingVars.length > 0) {
|
||||
throw new Error(
|
||||
`Missing required environment variables:\n${missingVars.map((name) => `- ${name}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assertRequiredEnvVars();
|
||||
|
||||
export function getEnv(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import {Page} from "@playwright/test";
|
||||
|
||||
|
||||
/**
|
||||
* Locate a notification channel card in the list.
|
||||
*
|
||||
* Executes from: `/dashboard/notifications/channels`.
|
||||
*/
|
||||
export function get(page: Page, channelName: string) {
|
||||
return page.locator('div.block.transition-all.duration-200.rounded-xl', {
|
||||
has: page.locator('h3', {hasText: channelName}),
|
||||
}).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the edit dialog for an existing notification channel.
|
||||
*
|
||||
* Executes from: `/dashboard/notifications/channels`.
|
||||
*/
|
||||
export async function edit(page: Page, channelName: string) {
|
||||
const card = get(page, channelName);
|
||||
await card.locator("button").nth(1).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing notification channel.
|
||||
*
|
||||
* Executes from: `/dashboard/notifications/channels`.
|
||||
*/
|
||||
export async function remove(page: Page, channelName: string) {
|
||||
const card = get(page, channelName);
|
||||
await card.locator("button").nth(2).click();
|
||||
await page.getByRole("button", {name: "Delete"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the notification channel creation form.
|
||||
*
|
||||
* Available entrypoints:
|
||||
* - `button`: the classic add button.
|
||||
* - `emptyState`: the empty-state CTA.
|
||||
* - `auto` use the classic add button and falls back to the empty-state CTA.
|
||||
*
|
||||
* Executes from: `/dashboard/notifications/channels`.
|
||||
*/
|
||||
export async function create(
|
||||
page: Page,
|
||||
provider: "Discord" | "Gotify" | "ntfy.sh" | "Slack" | "Email" | "Telegram" | "Webhook",
|
||||
channelName: string,
|
||||
fillConfig: (page: Page) => Promise<void>,
|
||||
entrypoint: "auto" | "emptyState" | "button" = "auto",
|
||||
) {
|
||||
if (entrypoint === "auto") {
|
||||
const addButton = page.getByRole("button", {name: /Add notification channel/i});
|
||||
if (await addButton.isVisible()) await page.getByRole("button", {name: /Add notification channel/i}).click();
|
||||
else await page.getByRole("button", {name: /No notification channels configured yet/i}).click();
|
||||
} else if (entrypoint === "button") {
|
||||
await page.getByRole("button", {name: /Add notification channel/i}).click();
|
||||
} else if (entrypoint === "emptyState") {
|
||||
await page.getByRole("button", {name: /No notification channels configured yet/i}).click();
|
||||
}
|
||||
|
||||
await page.getByText(provider, {exact: true}).click();
|
||||
await page.getByLabel(/Channel Name/).fill(channelName);
|
||||
await fillConfig(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the notification channel creation form.
|
||||
*
|
||||
* Executes from: the add notification channel dialog opened from `/dashboard/notifications/channels`.
|
||||
*/
|
||||
export async function submit(page: Page) {
|
||||
await page.getByRole("button", {name: "Add Channel"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the edit dialog for a notification channel and trigger the test action.
|
||||
*
|
||||
* Executes from: `/dashboard/notifications/channels`.
|
||||
*/
|
||||
export async function testFromEdit(page: Page, channelName: string) {
|
||||
await edit(page, channelName);
|
||||
await page.getByRole("button", {name: /Test Channel/i}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the current notification channel dialog without saving.
|
||||
*
|
||||
* Executes from: the add or edit notification channel dialog.
|
||||
*/
|
||||
export async function cancel(page: Page) {
|
||||
await page.getByRole("button", {name: "Cancel"}).click();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {Page} from "@playwright/test";
|
||||
|
||||
|
||||
/**
|
||||
* Locate a project card in the list.
|
||||
*
|
||||
* Executes from: `/dashboard/projects`.
|
||||
*/
|
||||
export function get(page: Page, projectName: string) {
|
||||
return page.locator('a[href^="/dashboard/projects/"]').filter({hasText: projectName}).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a project from the selected entrypoint.
|
||||
*
|
||||
* Available entrypoints:
|
||||
* - `emptyState`: the empty-state CTA.
|
||||
* - `button`: the classic create button.
|
||||
*
|
||||
* * Executes from: `/dashboard/projects`.
|
||||
*/
|
||||
export async function create(page: Page, entrypoint: "emptyState" | "button", projectName: string) {
|
||||
if (entrypoint === "emptyState") {
|
||||
await page.getByText("Create new Project", {exact: true}).click();
|
||||
} else {
|
||||
await page.getByRole("button", {name: /Create Project/i}).click();
|
||||
}
|
||||
|
||||
await page.getByLabel("Name").fill(projectName);
|
||||
await page.getByRole("button", {name: "Create"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an existing project from its details page.
|
||||
*
|
||||
* Executes from: `/dashboard/projects/[projectId]`.
|
||||
*/
|
||||
export async function edit(page: Page, currentName: string, updatedName: string) {
|
||||
await get(page, currentName).click();
|
||||
|
||||
await page
|
||||
.getByRole("button", {name: /Delete Project/i})
|
||||
.locator("xpath=ancestor::div[1]/preceding-sibling::div[1]/*[1]")
|
||||
.click();
|
||||
|
||||
await page.getByLabel("Name").fill(updatedName);
|
||||
await page.getByRole("button", {name: "Update"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing project from its details page.
|
||||
*
|
||||
* Executes from: `/dashboard/projects/[projectId]`.
|
||||
* */
|
||||
export async function remove(page: Page, projectName: string) {
|
||||
await get(page, projectName).click();
|
||||
await page.getByRole("button", {name: /Delete Project/i}).click();
|
||||
await page.getByRole("button", {name: "Delete", exact: true}).click();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const LOCAL_STORAGE_PATH = "./e2e/local-storage.json";
|
||||
@@ -0,0 +1,103 @@
|
||||
import {Page} from "@playwright/test";
|
||||
|
||||
|
||||
/**
|
||||
* Locate a storage channel card in the channels list.
|
||||
*
|
||||
* Executes from: `/dashboard/storages/channels`.
|
||||
*/
|
||||
export function get(page: Page, channelName: string) {
|
||||
return page.locator('div.block.transition-all.duration-200.rounded-xl', {
|
||||
has: page.locator('h3', {hasText: channelName}),
|
||||
}).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the storage channel creation form.
|
||||
*
|
||||
* Available entrypoints:
|
||||
* - `button`: the classic add button.
|
||||
* - `emptyState`: the empty-state CTA.
|
||||
* - `auto`: uses the classic add button and falls back to the empty-state CTA.
|
||||
*
|
||||
* Executes from: `/dashboard/storages/channels`.
|
||||
*/
|
||||
export async function create(
|
||||
page: Page,
|
||||
provider: "S3" | "Google Drive",
|
||||
channelName: string,
|
||||
fillConfig: (page: Page) => Promise<void>,
|
||||
entrypoint: "auto" | "emptyState" | "button" = "auto",
|
||||
) {
|
||||
if (entrypoint === "auto") {
|
||||
const addButton = page.getByRole("button", {name: /Add storage channel/i});
|
||||
if (await addButton.isVisible()) await page.getByRole("button", {name: /Add storage channel/i}).click();
|
||||
else await page.getByText("No storage channels configured yet", {exact: true}).click();
|
||||
} else if (entrypoint === "button") {
|
||||
await page.getByRole("button", {name: /Add storage channel/i}).click();
|
||||
} else if (entrypoint === "emptyState") {
|
||||
await page.getByText("No storage channels configured yet", {exact: true}).click();
|
||||
}
|
||||
|
||||
await page.getByText(provider, {exact: true}).click();
|
||||
await page.getByLabel(/Channel Name/).fill(channelName);
|
||||
await fillConfig(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the edit dialog for an existing storage channel.
|
||||
*
|
||||
* Executes from: `/dashboard/storages/channels`.
|
||||
*/
|
||||
export async function edit(page: Page, channelName: string) {
|
||||
const card = get(page, channelName);
|
||||
await card.locator("button").nth(1).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing storage channel.
|
||||
*
|
||||
* Executes from: `/dashboard/storages/channels`.
|
||||
*/
|
||||
export async function remove(page: Page, channelName: string) {
|
||||
const card = get(page, channelName);
|
||||
await card.locator("button").nth(2).click();
|
||||
await page.getByRole("button", {name: "Delete"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger the storage connection test in the current dialog.
|
||||
*
|
||||
* Executes from: the add or edit storage channel dialog opened from `/dashboard/storages/channels`.
|
||||
*/
|
||||
export async function testConnection(page: Page) {
|
||||
await page.getByRole("button", {name: /Test Storage/i}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the storage channel creation form.
|
||||
*
|
||||
* Executes from: the add storage channel dialog opened from `/dashboard/storages/channels`.
|
||||
*/
|
||||
export async function submit(page: Page) {
|
||||
await page.getByRole("button", {name: "Add Channel"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the edit dialog for a storage channel and trigger the test action.
|
||||
*
|
||||
* Executes from: `/dashboard/storages/channels`.
|
||||
*/
|
||||
export async function testFromEdit(page: Page, channelName: string) {
|
||||
await edit(page, channelName);
|
||||
await testConnection(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the current storage channel dialog without saving.
|
||||
*
|
||||
* Executes from: the add or edit storage channel dialog.
|
||||
*/
|
||||
export async function cancel(page: Page) {
|
||||
await page.getByRole("button", {name: "Cancel"}).click();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {
|
||||
cancel, create, get, remove, submit, testFromEdit,
|
||||
} from "../helpers/notification";
|
||||
import {getEnv} from "../helpers/env";
|
||||
import {LOCAL_STORAGE_PATH} from "../helpers/session";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const validChannelName = "Discord E2E Required";
|
||||
const invalidChannelName = "Discord E2E Invalid";
|
||||
|
||||
// test.describe.serial("Valid channel", () => {
|
||||
// test("Create and test a valid Discord channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "Discord", validChannelName, async (page) => {
|
||||
// await page.getByLabel(/Discord Webhook URL/).fill(getEnv("E2E_NOTIFICATION_DISCORD_WEBHOOK"));
|
||||
// });
|
||||
// await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, validChannelName)).toBeVisible();
|
||||
//
|
||||
// await testFromEdit(page, validChannelName);
|
||||
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toBeVisible();
|
||||
// await expect(page.getByText("Sent to Discord")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toHaveCount(0);
|
||||
// });
|
||||
//
|
||||
// test("Edit and test a valid Discord channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await expect(get(page, validChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, validChannelName);
|
||||
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toBeVisible();
|
||||
// await expect(page.getByText("Sent to Discord")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toHaveCount(0);
|
||||
// });
|
||||
// });
|
||||
|
||||
test.describe.serial("Invalid channel", () => {
|
||||
test("Create and test invalid Discord channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await create(page, "Discord", invalidChannelName, async (page) => {
|
||||
await page.getByLabel(/Discord Webhook URL/).fill("https://discord.com/api/webhooks/123456789012345678/wrong-discord-webhook-token");
|
||||
});
|
||||
await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
|
||||
await submit(page);
|
||||
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
|
||||
await testFromEdit(page, invalidChannelName);
|
||||
await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toBeVisible();
|
||||
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
|
||||
await cancel(page);
|
||||
await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Delete invalid Discord channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await remove(page, invalidChannelName);
|
||||
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
|
||||
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {
|
||||
cancel, create, get, remove, submit, testFromEdit,
|
||||
} from "../helpers/notification";
|
||||
import {getEnv} from "../helpers/env";
|
||||
import {LOCAL_STORAGE_PATH} from "../helpers/session";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const validChannelName = "Gotify E2E Required";
|
||||
const invalidChannelName = "Gotify E2E Invalid";
|
||||
|
||||
// test.describe.serial("Valid channel", () => {
|
||||
// test("Create and test a valid Gotify channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "Gotify", validChannelName, async (page) => {
|
||||
// await page.getByLabel(/Gotify Server URL/).fill(getEnv("E2E_NOTIFICATION_GOTIFY_SERVER_URL"));
|
||||
// await page.getByLabel(/Application Token/).fill(getEnv("E2E_NOTIFICATION_GOTIFY_APP_TOKEN"));
|
||||
// });
|
||||
// await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, validChannelName)).toBeVisible();
|
||||
//
|
||||
// await testFromEdit(page, validChannelName);
|
||||
// await expect(page.getByText("Sent to Gotify")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
//
|
||||
// test("Edit and test a valid Gotify channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await expect(get(page, validChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, validChannelName);
|
||||
// await expect(page.getByText("Sent to Gotify")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
// });
|
||||
|
||||
test.describe.serial("Invalid channel", () => {
|
||||
test("Create and test invalid Gotify channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await create(page, "Gotify", invalidChannelName, async (page) => {
|
||||
await page.getByLabel(/Gotify Server URL/).fill(getEnv("E2E_NOTIFICATION_GOTIFY_SERVER_URL"));
|
||||
await page.getByLabel(/Application Token/).fill("wrong-gotify-app-token");
|
||||
});
|
||||
await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
|
||||
await submit(page);
|
||||
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await testFromEdit(page, invalidChannelName);
|
||||
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
|
||||
await cancel(page);
|
||||
});
|
||||
|
||||
test("Delete invalid Gotify channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await remove(page, invalidChannelName);
|
||||
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
|
||||
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {
|
||||
cancel, create, get, remove, submit, testFromEdit,
|
||||
} from "../helpers/notification";
|
||||
import {getEnv} from "../helpers/env";
|
||||
import {LOCAL_STORAGE_PATH} from "../helpers/session";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const requiredChannelName = "Ntfy E2E Required";
|
||||
const optionalChannelName = "Ntfy E2E Optional";
|
||||
const invalidChannelName = "Ntfy E2E Invalid";
|
||||
|
||||
// test.describe.serial("Valid channels", () => {
|
||||
// test("Create and test a valid Ntfy channel with only required fields", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "ntfy.sh", requiredChannelName, async (page) => {
|
||||
// await page.getByLabel(/Topic Name/).fill(getEnv("E2E_NOTIFICATION_NTFY_TOPIC"));
|
||||
// });
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, requiredChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, requiredChannelName);
|
||||
// await expect(page.getByText("Sent to ntfy")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
//
|
||||
// test("Create and test a valid Ntfy channel with optional fields", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "ntfy.sh", optionalChannelName, async (page) => {
|
||||
// await page.getByLabel(/Topic Name/).fill(getEnv("E2E_NOTIFICATION_NTFY_TOPIC"));
|
||||
// await page.getByLabel(/^Server URL$/).fill(getEnv("E2E_NOTIFICATION_NTFY_SERVER_URL"));
|
||||
// await page.getByLabel(/^Access Token$/).fill(getEnv("E2E_NOTIFICATION_NTFY_TOKEN"));
|
||||
// await page.getByLabel(/^Basic Auth Username$/).fill(getEnv("E2E_NOTIFICATION_NTFY_USERNAME"));
|
||||
// await page.getByLabel(/^Basic Auth Password$/).fill(getEnv("E2E_NOTIFICATION_NTFY_PASSWORD"));
|
||||
// });
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, optionalChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, optionalChannelName);
|
||||
// await expect(page.getByText("Sent to ntfy")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
// });
|
||||
|
||||
test.describe.serial("Invalid channel", () => {
|
||||
test("Create and test invalid Ntfy channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await create(page, "ntfy.sh", invalidChannelName, async (page) => {
|
||||
await page.getByLabel(/Topic Name/).fill("wrong-ntfy-topic");
|
||||
await page.getByLabel(/^Server URL$/).fill(getEnv("E2E_NOTIFICATION_NTFY_SERVER_URL"));
|
||||
await page.getByLabel(/^Access Token$/).fill("wrong-ntfy-token");
|
||||
});
|
||||
await submit(page);
|
||||
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await testFromEdit(page, invalidChannelName);
|
||||
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
|
||||
await cancel(page);
|
||||
});
|
||||
|
||||
test("Delete invalid Ntfy channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await remove(page, invalidChannelName);
|
||||
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
|
||||
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {
|
||||
cancel, create, get, remove, submit, testFromEdit,
|
||||
} from "../helpers/notification";
|
||||
import {getEnv} from "../helpers/env";
|
||||
import {LOCAL_STORAGE_PATH} from "../helpers/session";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const validChannelName = "Slack E2E Required";
|
||||
const invalidChannelName = "Slack E2E Invalid";
|
||||
|
||||
// test.describe.serial("Valid channel", () => {
|
||||
// test("Create and test a valid Slack channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "Slack", validChannelName, async (page) => {
|
||||
// await page.getByLabel(/Slack Webhook URL/).fill(getEnv("E2E_NOTIFICATION_SLACK_WEBHOOK"));
|
||||
// });
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, validChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, validChannelName);
|
||||
// await expect(page.getByText("Sent to Slack")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
//
|
||||
// test("Edit and test a valid Slack E2E channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await expect(get(page, validChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, validChannelName);
|
||||
// await expect(page.getByText("Sent to Slack")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
// });
|
||||
|
||||
test.describe.serial("Invalid channel", () => {
|
||||
test("Create and test invalid Slack channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await create(page, "Slack", invalidChannelName, async (page) => {
|
||||
await page.getByLabel(/Slack Webhook URL/).fill("https://WRONG_SLACK_WEBHOOK");
|
||||
});
|
||||
await submit(page);
|
||||
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await testFromEdit(page, invalidChannelName);
|
||||
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
|
||||
await cancel(page);
|
||||
});
|
||||
|
||||
test("Delete invalid Slack channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await remove(page, invalidChannelName);
|
||||
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
|
||||
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {getEnv} from "../helpers/env";
|
||||
import {
|
||||
cancel, create, get, remove, submit, testFromEdit,
|
||||
} from "../helpers/notification";
|
||||
import {LOCAL_STORAGE_PATH} from "../helpers/session";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const validChannelName = "SMTP E2E Required";
|
||||
const invalidChannelName = "SMTP E2E Invalid";
|
||||
const successMessage = `Email sent: ${getEnv("E2E_NOTIFICATION_SMTP_TO")}`;
|
||||
|
||||
// test.describe.serial("Valid channel", () => {
|
||||
// test("Create and test a valid SMTP channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "Email", validChannelName, async (page) => {
|
||||
// await page.getByLabel(/SMTP Host/).fill(getEnv("E2E_NOTIFICATION_SMTP_HOST"));
|
||||
// await page.getByLabel(/SMTP Port/).fill(getEnv("E2E_NOTIFICATION_SMTP_PORT"));
|
||||
// await page.getByLabel(/Username/).fill(getEnv("E2E_NOTIFICATION_SMTP_USER"));
|
||||
// await page.getByLabel(/Password/).fill(getEnv("E2E_NOTIFICATION_SMTP_PASSWORD"));
|
||||
// await page.getByLabel(/From Email/).fill(getEnv("E2E_NOTIFICATION_SMTP_FROM"));
|
||||
// await page.getByLabel(/To Email/).fill(getEnv("E2E_NOTIFICATION_SMTP_TO"));
|
||||
// });
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, validChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, validChannelName);
|
||||
// await expect(page.getByText(successMessage)).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
//
|
||||
// test("Edit and test a valid SMTP channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await expect(get(page, validChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, validChannelName);
|
||||
// await expect(page.getByText(successMessage)).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
// });
|
||||
|
||||
test.describe.serial("Invalid channel", () => {
|
||||
test("Create and test invalid SMTP E2E channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await create(page, "Email", invalidChannelName, async (page) => {
|
||||
await page.getByLabel(/SMTP Host/).fill("smtp.invalid");
|
||||
await page.getByLabel(/SMTP Port/).fill(getEnv("E2E_NOTIFICATION_SMTP_PORT"));
|
||||
await page.getByLabel(/Username/).fill(getEnv("E2E_NOTIFICATION_SMTP_USER"));
|
||||
await page.getByLabel(/Password/).fill("wrong-password");
|
||||
await page.getByLabel(/From Email/).fill(getEnv("E2E_NOTIFICATION_SMTP_FROM"));
|
||||
await page.getByLabel(/To Email/).fill(getEnv("E2E_NOTIFICATION_SMTP_TO"));
|
||||
});
|
||||
await submit(page);
|
||||
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await testFromEdit(page, invalidChannelName);
|
||||
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
|
||||
await cancel(page);
|
||||
});
|
||||
|
||||
test("Delete invalid SMTP channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await remove(page, invalidChannelName);
|
||||
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
|
||||
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {
|
||||
cancel, create, get, remove, submit, testFromEdit,
|
||||
} from "../helpers/notification";
|
||||
import {getEnv} from "../helpers/env";
|
||||
import {LOCAL_STORAGE_PATH} from "../helpers/session";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const requiredChannelName = "Telegram E2E Required";
|
||||
const optionalChannelName = "Telegram E2E Optional";
|
||||
const invalidChannelName = "Telegram E2E Invalid";
|
||||
|
||||
// test.describe.serial("Valid channels", () => {
|
||||
// test("Create and test a valid Telegram channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "Telegram", requiredChannelName, async (page) => {
|
||||
// await page.getByLabel(/Telegram Bot Token/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN"));
|
||||
// await page.getByLabel(/Telegram Chat ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_CHAT_ID"));
|
||||
// });
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, requiredChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, requiredChannelName);
|
||||
// await expect(page.getByText("Sent to Telegram")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
//
|
||||
// test("Create and test a valid Telegram channel with Topic ID", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "Telegram", optionalChannelName, async (page) => {
|
||||
// await page.getByLabel(/Telegram Bot Token/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN"));
|
||||
// await page.getByLabel(/Telegram Chat ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_CHAT_ID"));
|
||||
// await page.getByLabel(/Telegram Topic ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_TOPIC_ID"));
|
||||
// });
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, optionalChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, optionalChannelName);
|
||||
// await expect(page.getByText("Sent to Telegram")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
// });
|
||||
|
||||
test.describe.serial("Invalid channel", () => {
|
||||
test("Create and test invalid Telegram channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await create(page, "Telegram", invalidChannelName, async (page) => {
|
||||
await page.getByLabel(/Telegram Bot Token/).fill("wrong-telegram-bot-token");
|
||||
await page.getByLabel(/Telegram Chat ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_CHAT_ID"));
|
||||
await page.getByLabel(/Telegram Topic ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_TOPIC_ID"));
|
||||
});
|
||||
await submit(page);
|
||||
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await testFromEdit(page, invalidChannelName);
|
||||
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
|
||||
await cancel(page);
|
||||
});
|
||||
|
||||
test("Delete invalid Telegram channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await remove(page, invalidChannelName);
|
||||
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
|
||||
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {
|
||||
cancel, create, get, remove, submit, testFromEdit,
|
||||
} from "../helpers/notification";
|
||||
import {getEnv} from "../helpers/env";
|
||||
import {LOCAL_STORAGE_PATH} from "../helpers/session";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const requiredChannelName = "Webhook E2E Required";
|
||||
const optionalChannelName = "Webhook E2E Optional";
|
||||
const invalidChannelName = "Webhook E2E Invalid";
|
||||
|
||||
// test.describe.serial("Valid channels", () => {
|
||||
// test("Create and test a valid Webhook channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "Webhook", requiredChannelName, async (page) => {
|
||||
// await page.getByLabel(/Webhook URL/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_URL"));
|
||||
// });
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, requiredChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, requiredChannelName);
|
||||
// await expect(page.getByText("Sent to Webhook")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
//
|
||||
// test("Create and test a valid Webhook channel with optional header", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "Webhook", optionalChannelName, async (page) => {
|
||||
// await page.getByLabel(/Webhook URL/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_URL"));
|
||||
// await page.getByLabel(/^Header Name$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER"));
|
||||
// await page.getByLabel(/^Secret Value$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET"));
|
||||
// });
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, optionalChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, optionalChannelName);
|
||||
// await expect(page.getByText("Sent to Webhook")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
// });
|
||||
|
||||
test.describe.serial("Invalid channel", () => {
|
||||
test("Create and test invalid Webhook channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await create(page, "Webhook", invalidChannelName, async (page) => {
|
||||
await page.getByLabel(/Webhook URL/).fill("https://webhook.example.com/api/wrong-webhook");
|
||||
await page.getByLabel(/^Header Name$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER"));
|
||||
await page.getByLabel(/^Secret Value$/).fill("wrong-webhook-secret");
|
||||
});
|
||||
await submit(page);
|
||||
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await testFromEdit(page, invalidChannelName);
|
||||
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
|
||||
await cancel(page);
|
||||
});
|
||||
|
||||
test("Delete invalid Webhook E2E channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await remove(page, invalidChannelName);
|
||||
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
|
||||
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {create, edit, get, remove} from "./helpers/project";
|
||||
import {LOCAL_STORAGE_PATH} from "./helpers/session";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const project = {
|
||||
emptyStateName: "Project A",
|
||||
buttonName: "Project B",
|
||||
updatedButtonName: "Project B Updated",
|
||||
};
|
||||
|
||||
test.describe.serial(() => {
|
||||
test("Create a project from empty state", async ({page}) => {
|
||||
await page.goto("/dashboard/projects");
|
||||
await expect(page.getByRole("heading", {name: "Projects"})).toBeVisible();
|
||||
await expect(page.getByText("Create new Project", {exact: true})).toBeVisible();
|
||||
|
||||
await create(page, "emptyState", project.emptyStateName);
|
||||
|
||||
await expect(page.getByText("Project has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, project.emptyStateName)).toBeVisible();
|
||||
await expect(page.getByText("Create new Project", {exact: true})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Create a project from classic button", async ({page}) => {
|
||||
await page.goto("/dashboard/projects");
|
||||
await expect(page.getByRole("heading", {name: "Projects"})).toBeVisible();
|
||||
await expect(get(page, project.emptyStateName)).toBeVisible();
|
||||
|
||||
await create(page, "button", project.buttonName);
|
||||
|
||||
await expect(page.getByText("Project has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, project.buttonName)).toBeVisible();
|
||||
});
|
||||
|
||||
test("Edit the second created project", async ({page}) => {
|
||||
await page.goto("/dashboard/projects");
|
||||
await expect(page.getByRole("heading", {name: "Projects"})).toBeVisible();
|
||||
await expect(get(page, project.buttonName)).toBeVisible();
|
||||
|
||||
await edit(page, project.buttonName, project.updatedButtonName);
|
||||
|
||||
await expect(page.getByText("Project has been successfully updated.")).toBeVisible();
|
||||
await expect(page.getByText(project.updatedButtonName, {exact: true})).toBeVisible();
|
||||
});
|
||||
|
||||
test("Delete the second created project", async ({page}) => {
|
||||
await page.goto("/dashboard/projects");
|
||||
await expect(page.getByRole("heading", {name: "Projects"})).toBeVisible();
|
||||
await expect(get(page, project.updatedButtonName)).toBeVisible();
|
||||
|
||||
await remove(page, project.updatedButtonName);
|
||||
|
||||
await expect(page).toHaveURL("/dashboard/projects");
|
||||
await expect(page.getByText("Projects has been successfully archived.")).toBeVisible();
|
||||
await expect(page.getByText(project.updatedButtonName)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import {test} from "@playwright/test";
|
||||
import fs from "fs";
|
||||
import {LOCAL_STORAGE_PATH} from "./helpers/session";
|
||||
|
||||
test.describe(() => {
|
||||
test.beforeAll(async () => {
|
||||
if (fs.existsSync(LOCAL_STORAGE_PATH)) fs.unlinkSync(LOCAL_STORAGE_PATH);
|
||||
fs.writeFileSync(LOCAL_STORAGE_PATH, JSON.stringify({}));
|
||||
});
|
||||
|
||||
test("Prepare shared storage state", async () => {
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {getEnv} from "../helpers/env";
|
||||
import {LOCAL_STORAGE_PATH} from "../helpers/session";
|
||||
import {
|
||||
cancel, create, get, remove, submit, testConnection, testFromEdit,
|
||||
} from "../helpers/storage";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const requiredChannelName = "Google Drive E2E Required";
|
||||
const invalidChannelName = "Google Drive E2E Invalid";
|
||||
|
||||
// test.describe.serial("Valid channel", () => {
|
||||
// test("Create and test a valid Google Drive channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/storages/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
|
||||
// await create(page, "Google Drive", requiredChannelName, async (page) => {
|
||||
// await page.getByLabel(/Client ID/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID"));
|
||||
// await page.getByLabel(/Client Secret/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET"));
|
||||
// await page.getByLabel(/Folder ID/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID"));
|
||||
// });
|
||||
// await expect(page.getByRole("heading", {name: "Add Storage Channel"})).toBeVisible();
|
||||
// await testConnection(page);
|
||||
// await expect(page.getByText("Successfully connected to storage channel")).toBeVisible();
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Storage channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, requiredChannelName)).toBeVisible();
|
||||
//
|
||||
// await testFromEdit(page, requiredChannelName);
|
||||
// await expect(page.getByRole("heading", {name: "Edit Storage Channel"})).toBeVisible();
|
||||
// await expect(page.getByText("Successfully connected to storage channel")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
// });
|
||||
|
||||
test.describe.serial("Invalid channel", () => {
|
||||
test("Create and test invalid Google Drive channel", async ({page}) => {
|
||||
await page.goto("/dashboard/storages/channels");
|
||||
await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
|
||||
await create(page, "Google Drive", invalidChannelName, async (page) => {
|
||||
await page.getByLabel(/Client ID/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID"));
|
||||
await page.getByLabel(/Client Secret/).fill("wrong-google-drive-client-secret");
|
||||
await page.getByLabel(/Folder ID/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID"));
|
||||
});
|
||||
await testConnection(page);
|
||||
await expect(page.getByText("An error occurred while testing the storage channel")).toBeVisible();
|
||||
await submit(page);
|
||||
await expect(page.getByText("Storage channel has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
});
|
||||
|
||||
test("Delete invalid Google Drive E2E channel", async ({page}) => {
|
||||
await page.goto("/dashboard/storages/channels");
|
||||
await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await remove(page, invalidChannelName);
|
||||
await expect(page.getByText("Storage channel has been successfully removed.")).toBeVisible();
|
||||
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {getEnv} from "../helpers/env";
|
||||
import {LOCAL_STORAGE_PATH} from "../helpers/session";
|
||||
import {
|
||||
cancel, create, get, remove, submit, testConnection, testFromEdit,
|
||||
} from "../helpers/storage";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const providers = [
|
||||
{
|
||||
title: "AWS S3",
|
||||
requiredChannelName: "AWS S3 E2E Required",
|
||||
optionalChannelName: "AWS S3 E2E Optional",
|
||||
invalidChannelName: "AWS S3 E2E Invalid",
|
||||
endpointUrl: getEnv("E2E_STORAGE_AWS_S3_ENDPOINT_URL"),
|
||||
region: getEnv("E2E_STORAGE_AWS_S3_REGION"),
|
||||
accessKey: getEnv("E2E_STORAGE_AWS_S3_ACCESS_KEY"),
|
||||
secretKey: getEnv("E2E_STORAGE_AWS_S3_SECRET_KEY"),
|
||||
bucketName: getEnv("E2E_STORAGE_AWS_S3_BUCKET_NAME"),
|
||||
port: getEnv("E2E_STORAGE_AWS_S3_PORT"),
|
||||
invalidAccessKey: "wrong-aws-access-key",
|
||||
invalidSecretKey: "wrong-aws-secret-key",
|
||||
},
|
||||
{
|
||||
title: "Cloudflare R2",
|
||||
requiredChannelName: "Cloudflare R2 E2E Required",
|
||||
optionalChannelName: "Cloudflare R2 E2E Optional",
|
||||
invalidChannelName: "Cloudflare R2 E2E Invalid",
|
||||
endpointUrl: getEnv("E2E_STORAGE_R2_ENDPOINT_URL"),
|
||||
region: getEnv("E2E_STORAGE_R2_REGION"),
|
||||
accessKey: getEnv("E2E_STORAGE_R2_ACCESS_KEY"),
|
||||
secretKey: getEnv("E2E_STORAGE_R2_SECRET_KEY"),
|
||||
bucketName: getEnv("E2E_STORAGE_R2_BUCKET_NAME"),
|
||||
port: getEnv("E2E_STORAGE_R2_PORT"),
|
||||
invalidAccessKey: "wrong-r2-access-key",
|
||||
invalidSecretKey: "wrong-r2-secret-key",
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const provider of providers) {
|
||||
test.describe(provider.title, () => {
|
||||
// test.describe.serial("Valid channels", () => {
|
||||
// test(`Create and test a valid ${provider.title} channel`, async ({page}) => {
|
||||
// await page.goto("/dashboard/storages/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
|
||||
// await create(page, "S3", provider.requiredChannelName, async (page) => {
|
||||
// await page.getByLabel(/Endpoint URL/).fill(provider.endpointUrl);
|
||||
// await page.getByLabel(/Access Key/).fill(provider.accessKey);
|
||||
// await page.getByLabel(/Secret Key/).fill(provider.secretKey);
|
||||
// await page.getByLabel(/Bucket name/).fill(provider.bucketName);
|
||||
// });
|
||||
// await expect(page.getByRole("heading", {name: "Add Storage Channel"})).toBeVisible();
|
||||
// await testConnection(page);
|
||||
// await expect(page.getByText("Successfully connected to storage channel")).toBeVisible();
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Storage channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, provider.requiredChannelName)).toBeVisible();
|
||||
//
|
||||
// await testFromEdit(page, provider.requiredChannelName);
|
||||
// await expect(page.getByRole("heading", {name: "Edit Storage Channel"})).toBeVisible();
|
||||
// await expect(page.getByText("Successfully connected to storage channel")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
//
|
||||
// test(`Create and test a valid ${provider.title} channel with optional region and port`, async ({page}) => {
|
||||
// await page.goto("/dashboard/storages/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
|
||||
// await create(page, "S3", provider.optionalChannelName, async (page) => {
|
||||
// await page.getByLabel(/Endpoint URL/).fill(provider.endpointUrl);
|
||||
// await page.getByLabel(/^Region$/).fill(provider.region);
|
||||
// await page.getByLabel(/Access Key/).fill(provider.accessKey);
|
||||
// await page.getByLabel(/Secret Key/).fill(provider.secretKey);
|
||||
// await page.getByLabel(/Bucket name/).fill(provider.bucketName);
|
||||
// await page.getByLabel(/^Port$/).fill(provider.port);
|
||||
// });
|
||||
// await expect(page.getByRole("heading", {name: "Add Storage Channel"})).toBeVisible();
|
||||
// await testConnection(page);
|
||||
// await expect(page.getByText("Successfully connected to storage channel")).toBeVisible();
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Storage channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, provider.optionalChannelName)).toBeVisible();
|
||||
//
|
||||
// await testFromEdit(page, provider.optionalChannelName);
|
||||
// await expect(page.getByRole("heading", {name: "Edit Storage Channel"})).toBeVisible();
|
||||
// await expect(page.getByText("Successfully connected to storage channel")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
// });
|
||||
|
||||
test.describe.serial("Invalid channel", () => {
|
||||
test(`Create and test invalid ${provider.title} channel`, async ({page}) => {
|
||||
await page.goto("/dashboard/storages/channels");
|
||||
await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
|
||||
await create(page, "S3", provider.invalidChannelName, async (page) => {
|
||||
await page.getByLabel(/Endpoint URL/).fill(provider.endpointUrl);
|
||||
await page.getByLabel(/^Region$/).fill(provider.region);
|
||||
await page.getByLabel(/Access Key/).fill(provider.invalidAccessKey);
|
||||
await page.getByLabel(/Secret Key/).fill(provider.invalidSecretKey);
|
||||
await page.getByLabel(/Bucket name/).fill(provider.bucketName);
|
||||
await page.getByLabel(/^Port$/).fill(provider.port);
|
||||
});
|
||||
await expect(page.getByRole("heading", {name: "Add Storage Channel"})).toBeVisible();
|
||||
await testConnection(page);
|
||||
await expect(page.getByText("An error occurred while testing the storage channel")).toBeVisible();
|
||||
await submit(page);
|
||||
await expect(page.getByText("Storage channel has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, provider.invalidChannelName)).toBeVisible();
|
||||
});
|
||||
|
||||
test(`Delete invalid ${provider.title} channel`, async ({page}) => {
|
||||
await page.goto("/dashboard/storages/channels");
|
||||
await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
|
||||
await expect(get(page, provider.invalidChannelName)).toBeVisible();
|
||||
await remove(page, provider.invalidChannelName);
|
||||
await expect(page.getByText("Storage channel has been successfully removed.")).toBeVisible();
|
||||
await expect(page.getByText(provider.invalidChannelName)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
@@ -43,6 +43,9 @@ const nextConfig: NextConfig = {
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
logging: {
|
||||
browserToTerminal: false,
|
||||
},
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "10gb",
|
||||
|
||||
+30
-27
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "portabase",
|
||||
"version": "1.4.10",
|
||||
"version": "1.11.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 8887",
|
||||
@@ -15,8 +15,8 @@
|
||||
"release": "release-it"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/passkey": "^1.4.19",
|
||||
"@better-auth/sso": "^1.4.19",
|
||||
"@better-auth/passkey": "^1.5.6",
|
||||
"@better-auth/sso": "^1.5.6",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
@@ -47,21 +47,20 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-email/components": "^0.0.41",
|
||||
"@t3-oss/env-nextjs": "^0.13.10",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@t3-oss/env-nextjs": "^0.13.11",
|
||||
"@tanstack/react-query": "^5.95.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/nodemailer": "^6.4.23",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@zenstackhq/runtime": "2.14.2",
|
||||
"argon2": "^0.43.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.4.18",
|
||||
"better-auth": "1.5.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dockerode": "^4.0.9",
|
||||
"dockerode": "^4.0.10",
|
||||
"dotenv": "^16.6.1",
|
||||
"drizzle-orm": "^0.43.1",
|
||||
"drizzle-zod": "^0.7.1",
|
||||
@@ -69,23 +68,25 @@
|
||||
"googleapis": "^170.1.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.553.0",
|
||||
"minio": "^8.0.6",
|
||||
"motion": "^12.34.3",
|
||||
"next": "16.1.5",
|
||||
"minio": "^8.0.7",
|
||||
"motion": "^12.38.0",
|
||||
"next": "16.2.1",
|
||||
"next-safe-action": "^7.10.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-forge": "^1.3.3",
|
||||
"node-forge": "^1.4.0",
|
||||
"nodemailer": "^7.0.13",
|
||||
"npm-check-updates": "^18.3.1",
|
||||
"pg": "^8.18.0",
|
||||
"pg": "^8.20.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prettier": "^3.8.1",
|
||||
"react": "^19.2.4",
|
||||
"react-day-picker": "9.7.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-dropzone": "^14.4.1",
|
||||
"react-email": "^4.3.2",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-hook-form": "^7.72.0",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-twc": "^1.5.1",
|
||||
@@ -95,42 +96,44 @@
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"swiper": "^12.1.2",
|
||||
"swiper": "^12.1.3",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"uuid": "^11.1.0",
|
||||
"vaul": "^1.1.2",
|
||||
"ws": "^8.19.0",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@playwright/test": "1.58.2",
|
||||
"@react-email/preview-server": "4.3.2",
|
||||
"@react-email/render": "^2.0.4",
|
||||
"@release-it/bumper": "^7.0.5",
|
||||
"@release-it/conventional-changelog": "^10.0.5",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@release-it/conventional-changelog": "^10.0.6",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||
"@types/node": "^22.19.11",
|
||||
"@types/node": "^22.19.15",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@zenstackhq/openapi": "^2.22.1",
|
||||
"@zenstackhq/tanstack-query": "^2.22.2",
|
||||
"baseline-browser-mapping": "^2.10.0",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"esbuild": "^0.27.3",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"esbuild": "^0.27.4",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "^16.2.1",
|
||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
||||
"framer-motion": "^12.34.3",
|
||||
"node-pty": "^1.1.0",
|
||||
"postcss": "^8.5.6",
|
||||
"release-it": "^19.2.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tsx": "^4.21.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"zenstack": "2.14.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
|
||||
"packageManager": "pnpm@10.33.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
baseURL: process.env.PROJECT_URL,
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
/* Configure projects and dependency order */
|
||||
projects: [
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: '**/setup.spec.ts',
|
||||
},
|
||||
{
|
||||
name: 'auth',
|
||||
testMatch: '**/auth.spec.ts',
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
{
|
||||
name: 'access-management',
|
||||
testMatch: '**/access-management.spec.ts',
|
||||
dependencies: ['auth'],
|
||||
},
|
||||
{
|
||||
name: 'notification',
|
||||
testMatch: '**/notification/**/*.spec.ts',
|
||||
dependencies: ['access-management'],
|
||||
},
|
||||
{
|
||||
name: 'storage',
|
||||
testMatch: '**/storage/**/*.spec.ts',
|
||||
dependencies: ['access-management'],
|
||||
},
|
||||
{
|
||||
name: 'agent',
|
||||
testMatch: '**/agent.spec.ts',
|
||||
dependencies: ['access-management'],
|
||||
},
|
||||
|
||||
{
|
||||
name: 'project',
|
||||
testMatch: '**/project.spec.ts',
|
||||
dependencies: ['access-management'],
|
||||
},
|
||||
{
|
||||
name: 'cleanup',
|
||||
testMatch: '**/cleanup.spec.ts',
|
||||
dependencies: ['agent', 'storage', 'notification', 'project'],
|
||||
},
|
||||
]
|
||||
|
||||
});
|
||||
Generated
+1041
-756
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
||||
ignoredBuiltDependencies:
|
||||
- node-pty
|
||||
onlyBuiltDependencies:
|
||||
- '@prisma/client'
|
||||
- '@prisma/engines'
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -209,14 +209,25 @@ type UseZodFormProps<Z extends ZodSchema> = Exclude<
|
||||
};
|
||||
|
||||
|
||||
// const useZodForm = <Z extends ZodSchema>({
|
||||
// schema,
|
||||
// ...formProps
|
||||
// }: UseZodFormProps<Z>) =>
|
||||
// useForm({
|
||||
// ...formProps,
|
||||
// // @ts-ignore
|
||||
// resolver: zodResolver(schema),
|
||||
// });
|
||||
|
||||
|
||||
const useZodForm = <Z extends ZodSchema>({
|
||||
schema,
|
||||
...formProps
|
||||
}: UseZodFormProps<Z>) =>
|
||||
useForm({
|
||||
...formProps,
|
||||
schema,
|
||||
...formProps
|
||||
}: UseZodFormProps<Z>): UseFormReturn<TypeOf<Z>> =>
|
||||
useForm<TypeOf<Z>>({
|
||||
...formProps,
|
||||
// @ts-ignore
|
||||
resolver: zodResolver(schema),
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
export {
|
||||
|
||||
@@ -37,6 +37,7 @@ export function SocialAuthButtons({ providers }: { providers: AuthProviderConfig
|
||||
providerType: "oidc",
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
console.log(result);
|
||||
} else {
|
||||
result = await authClient.signIn.social({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -44,7 +44,7 @@ export function BreadCrumbsWrapper() {
|
||||
)
|
||||
}
|
||||
|
||||
const FORBIDDEN_LINKS = ["organization", "dashboard", "database", "admin", "settings", "notifications"];
|
||||
const FORBIDDEN_LINKS = ["organization", "dashboard", "database", "admin", "settings", "notifications", "storages"];
|
||||
|
||||
|
||||
export function BreadCrumbs({}: BreadCrumbsProps) {
|
||||
|
||||
+2
-3
@@ -202,9 +202,9 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
|
||||
name="name"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Channel Name</FormLabel>
|
||||
<FormLabel>Channel Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={`My ${selectedProviderDetails?.label} Channel`}
|
||||
<Input {...field} placeholder={`e.g. ${selectedProviderDetails?.label} Channel`}
|
||||
value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
@@ -252,4 +252,3 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+35
-27
@@ -1,30 +1,38 @@
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierDiscordFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierDiscordForm = ({form}: NotifierDiscordFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.discordWebhook"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Discord Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://discord.com/api/webhooks/..."/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export const NotifierDiscordForm = ({ form }: NotifierDiscordFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.discordWebhook"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Discord Webhook URL *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
placeholder="e.g. https://discord.com/api/webhooks/..."
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+46
-40
@@ -1,43 +1,49 @@
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierGotifyFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierGotifyForm = ({form}: NotifierGotifyFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gotifyServerUrl"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Gotify Server URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://gotify.example.com"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gotifyAppToken"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Application Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="A1b2C3d4E5f6G7h"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export const NotifierGotifyForm = ({ form }: NotifierGotifyFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gotifyServerUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Gotify Server URL *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. https://gotify.example.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gotifyAppToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Application Token *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="e.g. gotify-app-token" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+10
-10
@@ -18,9 +18,9 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
||||
name="config.ntfyTopic"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Topic Name</FormLabel>
|
||||
<FormLabel>Topic Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="my-secret-topic"/>
|
||||
<Input {...field} placeholder="e.g. team-alerts"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
@@ -32,9 +32,9 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
||||
name="config.ntfyServerUrl"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server URL (Optional)</FormLabel>
|
||||
<FormLabel>Server URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://ntfy.sh (default)"/>
|
||||
<Input {...field} placeholder="e.g. https://ntfy.example.com"/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">Leave empty to use the official ntfy.sh server.</p>
|
||||
<FormMessage/>
|
||||
@@ -47,9 +47,9 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
||||
name="config.ntfyToken"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Access Token (Optional)</FormLabel>
|
||||
<FormLabel>Access Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="tk_..."/>
|
||||
<PasswordInput {...field} placeholder="e.g. tk_xxxxx"/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">Only required for protected topics or self-hosted instances with auth.</p>
|
||||
<FormMessage/>
|
||||
@@ -64,9 +64,9 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
||||
name="config.ntfyUsername"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Basic Auth Username (Optional)</FormLabel>
|
||||
<FormLabel>Basic Auth Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="username"/>
|
||||
<Input {...field} placeholder="e.g. notifier-user"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
@@ -78,9 +78,9 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
||||
name="config.ntfyPassword"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Basic Auth Password (Optional)</FormLabel>
|
||||
<FormLabel>Basic Auth Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder="password" {...field} />
|
||||
<PasswordInput placeholder="e.g. notifier-password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
|
||||
+35
-28
@@ -1,31 +1,38 @@
|
||||
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierSmtpFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierSlackForm = ({form}: NotifierSmtpFormProps) => {
|
||||
return(
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.slackWebhook"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slack Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://hooks.slack.com/services/..."/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export const NotifierSlackForm = ({ form }: NotifierSmtpFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.slackWebhook"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slack Webhook URL *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
placeholder="e.g. https://hooks.slack.com/services/..."
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+12
-12
@@ -19,9 +19,9 @@ export const NotifierSmtpForm = ({form}: NotifierSmtpFormProps) => {
|
||||
name="config.host"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Host</FormLabel>
|
||||
<FormLabel>SMTP Host *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="smtp.gmail.com" {...field} value={field.value ?? ""}/>
|
||||
<Input placeholder="e.g. smtp.example.com" {...field} value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
@@ -32,9 +32,9 @@ export const NotifierSmtpForm = ({form}: NotifierSmtpFormProps) => {
|
||||
name="config.port"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Port</FormLabel>
|
||||
<FormLabel>SMTP Port *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="456" type="number" {...field} value={field.value ?? ""}/>
|
||||
<Input placeholder="e.g. 587" type="number" {...field} value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
@@ -45,9 +45,9 @@ export const NotifierSmtpForm = ({form}: NotifierSmtpFormProps) => {
|
||||
name="config.user"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormLabel>Username *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="contact@example.com" {...field} value={field.value ?? ""}/>
|
||||
<Input placeholder="e.g. noreply@example.com" {...field} value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
@@ -58,7 +58,7 @@ export const NotifierSmtpForm = ({form}: NotifierSmtpFormProps) => {
|
||||
name="config.password"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormLabel>Password *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
@@ -75,9 +75,9 @@ export const NotifierSmtpForm = ({form}: NotifierSmtpFormProps) => {
|
||||
name="config.from"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>From Email</FormLabel>
|
||||
<FormLabel>From Email *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={`"Portabase" <example@portabase.io>`} {...field}
|
||||
<Input placeholder={`e.g. "Portabase" <noreply@example.com>`} {...field}
|
||||
value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
@@ -90,9 +90,9 @@ export const NotifierSmtpForm = ({form}: NotifierSmtpFormProps) => {
|
||||
name="config.to"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>To Email</FormLabel>
|
||||
<FormLabel>To Email *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"contact@portabase.io, contact2@portabase.io"} {...field}
|
||||
<Input placeholder={"e.g. alerts@example.com, team@example.com"} {...field}
|
||||
value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
@@ -102,4 +102,4 @@ export const NotifierSmtpForm = ({form}: NotifierSmtpFormProps) => {
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+81
-61
@@ -1,64 +1,84 @@
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierTelegramFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierTelegramForm = ({form}: NotifierTelegramFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.telegramBotToken"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telegram Bot Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.telegramChatId"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telegram Chat ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="-100123456789 or 123456789"/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You must start the conversation with the bot first (<strong>/start</strong>). <br/>
|
||||
For groups, add the bot to the group. <br/>
|
||||
You can use <a href="https://t.me/userinfobot" target="_blank" rel="noopener noreferrer" className="underline">@userinfobot</a> to find your ID.
|
||||
</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export const NotifierTelegramForm = ({ form }: NotifierTelegramFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.telegramBotToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telegram Bot Token *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
placeholder="e.g. 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.telegramChatId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telegram Chat ID *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. -100123456789 or 123456789" />
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You must start the conversation with the bot first (
|
||||
<strong>/start</strong>). <br />
|
||||
For groups, add the bot to the group. <br />
|
||||
You can use{" "}
|
||||
<a
|
||||
href="https://t.me/userinfobot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
@userinfobot
|
||||
</a>{" "}
|
||||
to find your ID.
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.telegramTopicId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telegram Topic ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. 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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+69
-62
@@ -1,65 +1,72 @@
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierWebhookFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierWebhookForm = ({form}: NotifierWebhookFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookUrl"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://example.com/api/webhook"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookSecretHeader"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Header Name (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="X-Webhook-Secret"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookSecret"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Value (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Secret value..."/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
If provided, the secret will be sent in the specified header (defaults to <code>X-Webhook-Secret</code>).
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export const NotifierWebhookForm = ({ form }: NotifierWebhookFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. https://example.com/api/webhook" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookSecretHeader"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Header Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. X-Webhook-Secret" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Value</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="e.g. webhook-secret" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
If provided, the secret will be sent in the specified header (defaults
|
||||
to <code>X-Webhook-Secret</code>).
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+5
-5
@@ -96,9 +96,9 @@ export const StorageGoogleDriveForm = ({form}: StorageGoogleDriveFormProps) => {
|
||||
name="config.clientId"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client ID</FormLabel>
|
||||
<FormLabel>Client ID *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="xxxx.apps.googleusercontent.com"/>
|
||||
<Input {...field} placeholder="e.g. xxxx.apps.googleusercontent.com"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
@@ -110,7 +110,7 @@ export const StorageGoogleDriveForm = ({form}: StorageGoogleDriveFormProps) => {
|
||||
name="config.clientSecret"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client Secret</FormLabel>
|
||||
<FormLabel>Client Secret *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} />
|
||||
</FormControl>
|
||||
@@ -124,9 +124,9 @@ export const StorageGoogleDriveForm = ({form}: StorageGoogleDriveFormProps) => {
|
||||
name="config.folderId"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Folder ID</FormLabel>
|
||||
<FormLabel>Folder ID *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input {...field} placeholder="e.g. 1AbCdEfGhIjKlMnOpQrStUvWxYz"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
|
||||
+112
-111
@@ -1,115 +1,116 @@
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import {Switch} from "@/components/ui/switch";
|
||||
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
type StorageS3FormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const StorageS3Form = ({form}: StorageS3FormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.endPointUrl"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoint URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="s3.amazonaws.com"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.region"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Region</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="us-east-1"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.accessKey"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Access Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="A1b2C3d4E5f6G7h"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.secretKey"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Key</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="A1b2C3d4E5f6G7h"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.bucketName"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bucket name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="portabase-dev"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="number" placeholder="443" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
export const StorageS3Form = ({ form }: StorageS3FormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.endPointUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoint URL *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. s3.amazonaws.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.region"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Region</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. us-east-1" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.accessKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Access Key *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. AKIA..." />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.secretKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Key *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="e.g. s3-secret-key" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.bucketName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bucket name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. backups-prod" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="number" placeholder="e.g. 443" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Use SSL</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Use SSL</FormLabel>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+5
-1
@@ -6,6 +6,10 @@ export const S3ChannelConfigSchema = z.object({
|
||||
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(),
|
||||
port: z.union([
|
||||
z.literal("").transform(() => ""),
|
||||
z.literal("").transform(() => ""),
|
||||
z.coerce.number()
|
||||
]).optional(),
|
||||
ssl: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
+232
-53
@@ -1,62 +1,241 @@
|
||||
"use client"
|
||||
import {Eye} from "lucide-react";
|
||||
|
||||
import {useState} from "react";
|
||||
"use client";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
AlertCircle,
|
||||
Braces,
|
||||
Database,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
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";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {NotificationLogWithRelations} from "@/db/services/notification-log";
|
||||
import type { NotificationLogWithRelations } from "@/db/services/notification-log";
|
||||
|
||||
type NotificationLogModalProps = {
|
||||
notificationLog: NotificationLogWithRelations;
|
||||
}
|
||||
notificationLog: NotificationLogWithRelations;
|
||||
};
|
||||
|
||||
export const NotificationLogModal = ({notificationLog}: NotificationLogModalProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const getTroubleshootingForError = (errorMsg?: any) => {
|
||||
if (!errorMsg || String(errorMsg).toLowerCase() === "null") return null;
|
||||
|
||||
return (
|
||||
<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"/>
|
||||
const msg = String(errorMsg).toLowerCase();
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div>
|
||||
<span className="font-semibold">Title:</span>{" "}
|
||||
<span>{notificationLog.content.title}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Message:</span>{" "}
|
||||
<span>{notificationLog.content.message}</span>
|
||||
</div>
|
||||
if (msg.includes("database connection") || msg.includes("agent")) {
|
||||
return {
|
||||
title: "Agent Connection Issue",
|
||||
resolution:
|
||||
"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.",
|
||||
docLink:
|
||||
"https://portabase.io/docs/agent/troubleshooting/agent-connection",
|
||||
};
|
||||
}
|
||||
|
||||
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>
|
||||
<span className="font-semibold text-sm text-primary">
|
||||
JSON Payload
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notificationLog.payload && (
|
||||
<>
|
||||
<Separator className="mb-3" />
|
||||
<div className="text-sm font-mono bg-gray-100 dark:bg-gray-800 dark:text-gray-200 p-4 rounded-md overflow-auto">
|
||||
{JSON.stringify(notificationLog.payload, null, 2)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
<div className="flex flex-col">
|
||||
{Object.entries(notificationLog.payload).map(
|
||||
([key, value], index, arr) => {
|
||||
const isActualError =
|
||||
key === "error" &&
|
||||
value !== null &&
|
||||
String(value).toLowerCase() !== "null";
|
||||
|
||||
return (
|
||||
<div
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export const EmailFormSchema = z.object({
|
||||
smtpHost: z.string(),
|
||||
smtpPort: z.string(),
|
||||
smtpUser: z.string(),
|
||||
smtpSecure: z.boolean()
|
||||
});
|
||||
|
||||
export type EmailFormType = z.infer<typeof EmailFormSchema>;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import {Card, CardContent} from "@/components/ui/card";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
@@ -31,6 +30,7 @@ import {render} from "@react-email/components";
|
||||
import EmailSettingsTest from "@/components/emails/email-settings-test";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {Send} from "lucide-react";
|
||||
import {Switch} from "@/components/ui/switch";
|
||||
|
||||
export type EmailFormProps = {
|
||||
defaultValues?: EmailFormType;
|
||||
@@ -179,6 +179,23 @@ export const EmailForm = (props: EmailFormProps) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="smtpSecure"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secure</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-between gap-4">
|
||||
{props.defaultValues?.smtpFrom && (
|
||||
<Tooltip>
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
"use client"
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
|
||||
import {Info, Send} from "lucide-react";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
useZodForm
|
||||
} from "@/components/ui/form";
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {
|
||||
DefaultNotificationSchema, DefaultNotificationType
|
||||
} from "@/components/wrappers/dashboard/admin/settings/notification/settings-notification.schema";
|
||||
import {
|
||||
updateNotificationSettingsAction
|
||||
} from "@/components/wrappers/dashboard/admin/settings/notification/settings-notification.action";
|
||||
import {toast} from "sonner";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
|
||||
export type SettingsNotificationSectionProps = {
|
||||
settings: Setting;
|
||||
notificationChannels: NotificationChannelWith[];
|
||||
};
|
||||
|
||||
export const SettingsNotificationSection = ({settings, notificationChannels}: SettingsNotificationSectionProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const form = useZodForm({
|
||||
schema: DefaultNotificationSchema,
|
||||
defaultValues: {
|
||||
notificationChannelId: settings.defaultNotificationChannelId ?? "",
|
||||
}
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: DefaultNotificationType) => {
|
||||
const result = await updateNotificationSettingsAction({name: "system", data: values})
|
||||
const inner = result?.data;
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<Alert className="mt-3 flex items-start gap-2">
|
||||
<Info className="h-4 w-4 mt-1"/>
|
||||
<div>
|
||||
<AlertTitle>Informations</AlertTitle>
|
||||
<AlertDescription className="flex flex-wrap items-center gap-1">
|
||||
The default notification channel will be used to send
|
||||
<Badge>error_health_agent</Badge>
|
||||
<Badge>error_health_database</Badge>
|
||||
<Badge>error_backup</Badge>
|
||||
<Badge>error_restore</Badge>
|
||||
events. For more options like notify when success, please set policy at database level
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
<div className="flex flex-col h-full py-4 gap-3">
|
||||
<Form
|
||||
className="space-y-4"
|
||||
form={form}
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notificationChannelId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-grow ">
|
||||
<FormLabel>Default Notification Provider</FormLabel>
|
||||
{notificationChannels.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No channel available</div>
|
||||
) : (
|
||||
<Select
|
||||
value={field.value ?? ""}
|
||||
onValueChange={(value) => field.onChange(value)}
|
||||
>
|
||||
<SelectTrigger className="w-full h-full mb-0">
|
||||
<SelectValue placeholder="Select a default channel" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{notificationChannels.map((channel) => (
|
||||
<SelectItem key={channel.id} value={channel.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
{getChannelIcon(channel.provider)}
|
||||
<span className="font-medium">{channel.name}</span>
|
||||
<span className="text-[9px] uppercase bg-secondary px-1.5 py-0.5 rounded">
|
||||
{channel.provider}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex justify-between gap-4">
|
||||
|
||||
{notificationChannels.length > 0 && (
|
||||
<ButtonWithLoading
|
||||
type="submit"
|
||||
>
|
||||
Confirm
|
||||
</ButtonWithLoading>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
{notificationChannels.length > 0 && form.getValues("notificationChannelId") ? (
|
||||
<ButtonWithLoading
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
form.setValue("notificationChannelId", "");
|
||||
await mutation.mutateAsync({
|
||||
notificationChannelId: null,
|
||||
});
|
||||
}}
|
||||
className="flex-shrink-0 w-full sm:w-auto"
|
||||
>
|
||||
Reset
|
||||
</ButtonWithLoading>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
"use server"
|
||||
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
import {z} from "zod";
|
||||
import {
|
||||
DefaultNotificationSchema
|
||||
} from "@/components/wrappers/dashboard/admin/settings/notification/settings-notification.schema";
|
||||
|
||||
export const updateNotificationSettingsAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
data: DefaultNotificationSchema,
|
||||
})
|
||||
)
|
||||
.action(async ({parsedInput}): Promise<ServerActionResult<Setting>> => {
|
||||
const {name, data} = parsedInput;
|
||||
|
||||
try {
|
||||
const [updatedSettings] = await db
|
||||
.update(drizzleDb.schemas.setting)
|
||||
.set({
|
||||
defaultNotificationChannelId: data.notificationChannelId ?? null,
|
||||
})
|
||||
.where(eq(drizzleDb.schemas.setting.name, name))
|
||||
.returning();
|
||||
return {
|
||||
success: true,
|
||||
value: updatedSettings,
|
||||
actionSuccess: {
|
||||
message: "Settings successfully updated",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed update settings.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const DefaultNotificationSchema = z.object({
|
||||
notificationChannelId: z.string().optional().nullable()
|
||||
});
|
||||
|
||||
export type DefaultNotificationType = z.infer<typeof DefaultNotificationSchema>;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user