mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
8655b81618 | ||
|
|
1702e43373 | ||
|
|
79425f4490 | ||
|
|
2fcddea0fa | ||
|
|
a43eedaf9f | ||
|
|
77bc24990e | ||
|
|
edbc79c89e | ||
|
|
1ce3acdde0 |
@@ -6,7 +6,6 @@ DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public
|
|||||||
|
|
||||||
# Project
|
# Project
|
||||||
PROJECT_NAME="Portabase"
|
PROJECT_NAME="Portabase"
|
||||||
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
|
|
||||||
PROJECT_URL=http://localhost:8887
|
PROJECT_URL=http://localhost:8887
|
||||||
PROJECT_SECRET=
|
PROJECT_SECRET=
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ representative at an online or offline event.
|
|||||||
|
|
||||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
reported to the community leaders responsible for enforcement at
|
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 complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
All community leaders are obligated to respect the privacy and security of the
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
|||||||
+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.
|
If you discover a security vulnerability in this project, we appreciate your help in disclosing it responsibly.
|
||||||
|
|
||||||
1. **Contact Us**
|
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.
|
- A detailed description of the issue.
|
||||||
- Steps to reproduce the vulnerability (if applicable).
|
- Steps to reproduce the vulnerability (if applicable).
|
||||||
- Any potential impacts or risks.
|
- Any potential impacts or risks.
|
||||||
|
|||||||
@@ -1,47 +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
|
|
||||||
git push origin --tags
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
||||||
@@ -3,6 +3,9 @@ name: Discord Notification
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
discord_title:
|
discord_title:
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
@@ -27,10 +30,10 @@ jobs:
|
|||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||||
run: |
|
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)
|
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_URL=$(echo "$RELEASE_INFO" | jq -r .url)
|
||||||
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body)
|
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body)
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ name: Docker Publish
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
|
version:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
ref:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
image_name:
|
image_name:
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
@@ -32,10 +38,13 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
platform: [linux/amd64, linux/arm64]
|
platform: [ linux/amd64, linux/arm64 ]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
@@ -50,9 +59,10 @@ jobs:
|
|||||||
id: prep
|
id: prep
|
||||||
run: |
|
run: |
|
||||||
ARCH=${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
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
|
echo "safe_platform=${{ matrix.platform == 'linux/amd64' && 'linux-amd64' || 'linux-arm64' }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Build and push image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
@@ -95,20 +105,38 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Generate semantic tags
|
||||||
|
id: tags
|
||||||
|
run: |
|
||||||
|
VERSION="${{ inputs.version }}"
|
||||||
|
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
|
||||||
|
|
||||||
|
echo "VERSION_TAG=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
if [ -n "$MINOR" ]; then
|
||||||
|
echo "MINOR_TAG=$MAJOR.$MINOR" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
if [ -n "$MAJOR" ]; then
|
||||||
|
echo "MAJOR_TAG=$MAJOR" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
if [ "${{ inputs.add_latest }}" = "true" ]; then
|
||||||
|
echo "LATEST_TAG=latest" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Extract Docker metadata
|
- name: Extract Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ inputs.image_name }}
|
images: ${{ inputs.image_name }}
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern={{version}}
|
type=raw,value=${{ steps.tags.outputs.VERSION_TAG }}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=raw,value=${{ steps.tags.outputs.MINOR_TAG }}
|
||||||
type=semver,pattern={{major}}
|
type=raw,value=${{ steps.tags.outputs.MAJOR_TAG }}
|
||||||
type=raw,value=latest,enable=${{ inputs.add_latest }}
|
type=raw,value=${{ steps.tags.outputs.LATEST_TAG }}
|
||||||
|
|
||||||
- name: Create and push manifest list
|
- name: Create and push manifest list
|
||||||
working-directory: /tmp/digests
|
working-directory: /tmp/digests
|
||||||
run: |
|
run: |
|
||||||
DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)"
|
DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)"
|
||||||
TAG_ARGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")
|
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,77 @@
|
|||||||
|
name: End-to-end testing
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
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 firefox webkit
|
||||||
|
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: pnpm exec playwright test --project chromium
|
||||||
|
env:
|
||||||
|
PROJECT_URL: http://localhost:8887
|
||||||
|
|
||||||
|
- 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
|
|
||||||
+113
-15
@@ -1,43 +1,141 @@
|
|||||||
name: Publish Docker image for release
|
name: Auto Release & Publish
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
pull_request:
|
||||||
tags:
|
types: [ closed ]
|
||||||
- "*.*.*"
|
branches:
|
||||||
- "!*-*"
|
- main
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
jobs:
|
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
|
uses: ./.github/workflows/docker.yml
|
||||||
with:
|
with:
|
||||||
|
version: ${{ needs.create-release.outputs.version }}
|
||||||
|
ref: ${{ needs.create-release.outputs.version }}
|
||||||
add_latest: true
|
add_latest: true
|
||||||
secrets:
|
secrets:
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||||
publish_release:
|
|
||||||
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
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
release_tag: ${{ steps.publish_release_step.outputs.release_tag }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Publish GitHub Release
|
- name: Publish GitHub Release
|
||||||
|
id: publish_release_step
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
gh release edit ${{ github.ref_name }} --draft=false
|
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
|
||||||
|
|
||||||
discord_notification:
|
notify-discord:
|
||||||
needs: docker_publish
|
needs:
|
||||||
|
- publish-docker
|
||||||
|
- publish-helm
|
||||||
|
- create-release
|
||||||
|
- finalize-release
|
||||||
uses: ./.github/workflows/discord.yml
|
uses: ./.github/workflows/discord.yml
|
||||||
with:
|
with:
|
||||||
discord_title: "New Release: ${{ github.ref_name }}"
|
release_tag: ${{ needs.create-release.outputs.version }}
|
||||||
|
discord_title: "New Release"
|
||||||
discord_color: 3066993
|
discord_color: 3066993
|
||||||
discord_footer: "Portabase"
|
discord_footer: "Portabase"
|
||||||
secrets:
|
secrets:
|
||||||
|
|||||||
@@ -9,14 +9,13 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: aquasecurity/trivy-action@0.20.0
|
- uses: aquasecurity/trivy-action@0.35.0
|
||||||
with:
|
with:
|
||||||
scan-type: 'fs'
|
scan-type: 'fs'
|
||||||
format: 'table'
|
format: 'table'
|
||||||
severity: 'CRITICAL,HIGH'
|
severity: 'CRITICAL,HIGH'
|
||||||
ignore-unfixed: true
|
ignore-unfixed: true
|
||||||
|
|
||||||
|
|
||||||
secrets-gitleaks:
|
secrets-gitleaks:
|
||||||
if: github.event.pull_request.head.repo.fork == false
|
if: github.event.pull_request.head.repo.fork == false
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -47,3 +47,10 @@ private/keys/*
|
|||||||
seeds/pocket-id/*.db
|
seeds/pocket-id/*.db
|
||||||
|
|
||||||
certificates
|
certificates
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/blob-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
/playwright/.auth/
|
||||||
|
|||||||
+6
-11
@@ -1,22 +1,17 @@
|
|||||||
{
|
{
|
||||||
|
"github": {
|
||||||
|
"release": true,
|
||||||
|
"draft": true,
|
||||||
|
"tokenRef": "GITHUB_TOKEN"
|
||||||
|
},
|
||||||
"git": {
|
"git": {
|
||||||
"commit": true,
|
"commit": true,
|
||||||
"commitMessage": "chore: release v${version}",
|
"commitMessage": "chore: release ${version}",
|
||||||
"requireCleanWorkingDir": true,
|
"requireCleanWorkingDir": true,
|
||||||
"tag": true,
|
"tag": true,
|
||||||
"tagName": "${version}",
|
"tagName": "${version}",
|
||||||
"push": true
|
"push": true
|
||||||
},
|
},
|
||||||
"github": {
|
|
||||||
"release": true,
|
|
||||||
"tokenRef": "GITHUB_TOKEN",
|
|
||||||
"draft": true,
|
|
||||||
"tagName": "${version}",
|
|
||||||
"push": true
|
|
||||||
},
|
|
||||||
"hooks": {
|
|
||||||
"after:git:push": "sleep 5"
|
|
||||||
},
|
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"@release-it/conventional-changelog": {
|
"@release-it/conventional-changelog": {
|
||||||
"preset": {
|
"preset": {
|
||||||
|
|||||||
+1
-1
@@ -31,5 +31,5 @@ keywords:
|
|||||||
- web-ui
|
- web-ui
|
||||||
- agent
|
- agent
|
||||||
license: Apache-2.0
|
license: Apache-2.0
|
||||||
version: 1.4.22
|
version: 1.9.3
|
||||||
date-released: '2026-03-02'
|
date-released: '2026-03-02'
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://hub.docker.com/r/solucetechnologies/portabase)
|
[](https://hub.docker.com/r/portabase/portabase)
|
||||||
[](https://github.com/Portabase/portabase)
|
[](https://github.com/Portabase/portabase)
|
||||||
[](https://www.buymeacoffee.com/portabase)
|
[](https://www.buymeacoffee.com/portabase)
|
||||||
|
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
[](https://www.mysql.com/)
|
[](https://www.mysql.com/)
|
||||||
[](https://mariadb.org/)
|
[](https://mariadb.org/)
|
||||||
[](https://sqlite.org/)
|
[](https://sqlite.org/)
|
||||||
|
[](https://redis.io/)
|
||||||
|
[](https://valkey.io/)
|
||||||
[](https://www.mongodb.com/)
|
[](https://www.mongodb.com/)
|
||||||
[](https://github.com/Portabase/portabase)
|
[](https://github.com/Portabase/portabase)
|
||||||
[](https://github.com/Portabase/portabase)
|
[](https://github.com/Portabase/portabase)
|
||||||
@@ -53,11 +55,26 @@ You have 4 ways to install Portabase:
|
|||||||
- Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli)
|
- Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli)
|
||||||
- Docker Run - [details](https://portabase.io/docs/dashboard/setup#docker)
|
- Docker Run - [details](https://portabase.io/docs/dashboard/setup#docker)
|
||||||
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker-compose)
|
- 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)
|
- Development setup - [details](https://portabase.io/docs/dashboard/setup#development)
|
||||||
|
|
||||||
**Ensure Docker is installed on your machine before getting started.**
|
**Ensure Docker is installed on your machine before getting started.**
|
||||||
|
|
||||||
|
## Supported databases
|
||||||
|
|
||||||
|
| Engine | Support | Supported Versions | Restore |
|
||||||
|
|:-------------------|:----------|:----------------------------------|:--------|
|
||||||
|
| **PostgreSQL** | ✅ Stable | 12, 13, 14, 15, 16, 17 et 18 | Yes |
|
||||||
|
| **MySQL** | ✅ Stable | 5.7, 8 et 9 | Yes |
|
||||||
|
| **MariaDB** | ✅ Stable | 10 et 11 | Yes |
|
||||||
|
| **MongoDB** | ✅ Stable | 4, 5, 6, 7 et 8 | Yes |
|
||||||
|
| **SQLite** | ✅ Stable | 3.x | Yes |
|
||||||
|
| **Redis** | ✅ Stable | 2.8+ | No |
|
||||||
|
| **Valkey** | ✅ Stable | 7.2+ | No |
|
||||||
|
| **MSSQL Server** | ❌ Ongoing | - | Yes |
|
||||||
|
|
||||||
|
See the [Database Servers documentation](https://portabase.io/docs/agent/db) for version-specific backup and restore details.
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
[](https://github.com/Portabase/portabase/graphs/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 { redirect } from "next/navigation";
|
||||||
import { currentUser } from "@/lib/auth/current-user";
|
import { currentUser } from "@/lib/auth/current-user";
|
||||||
import { AuthLogoSection } from "@/components/wrappers/auth/auth-logo-section";
|
import { AuthLogoSection } from "@/components/wrappers/auth/auth-logo-section";
|
||||||
import { env } from "@/env.mjs";
|
|
||||||
import { Heart } from "lucide-react";
|
import { Heart } from "lucide-react";
|
||||||
import { ErrorLayout } from "@/components/wrappers/common/error-layout";
|
|
||||||
|
|
||||||
export default async function Layout({
|
export default async function Layout({
|
||||||
children,
|
children,
|
||||||
@@ -31,7 +29,6 @@ export default async function Layout({
|
|||||||
<Heart className="size-3 fill-red-500 text-red-500 animate-pulse" />{" "}
|
<Heart className="size-3 fill-red-500 text-red-500 animate-pulse" />{" "}
|
||||||
by <span className="font-medium text-foreground">Portabase</span>
|
by <span className="font-medium text-foreground">Portabase</span>
|
||||||
</p>
|
</p>
|
||||||
<p>v{env.NEXT_PUBLIC_PROJECT_VERSION}</p>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import Link from "next/link";
|
|||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { CardAuth } from "@/features/layout/card-auth";
|
import { CardAuth } from "@/features/layout/card-auth";
|
||||||
import { env } from "@/env.mjs";
|
import { env } from "@/env.mjs";
|
||||||
import { en } from "zod/v4/locales";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Login",
|
title: "Login",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {SettingsTabs} from "@/components/wrappers/dashboard/admin/settings/setti
|
|||||||
import {desc, isNull} from "drizzle-orm";
|
import {desc, isNull} from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||||
|
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
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"),
|
where: (fields, {eq}) => eq(fields.name, "system"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(settings)
|
||||||
|
|
||||||
const storageChannels = await db.query.storageChannel.findMany({
|
const storageChannels = await db.query.storageChannel.findMany({
|
||||||
with: {
|
with: {
|
||||||
organizations: true
|
organizations: true
|
||||||
@@ -21,7 +24,16 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
orderBy: desc(drizzleDb.schemas.storageChannel.createdAt)
|
orderBy: desc(drizzleDb.schemas.storageChannel.createdAt)
|
||||||
}) as StorageChannelWith[]
|
}) 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()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +45,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
</div>
|
</div>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PageContent className="flex flex-col gap-5">
|
<PageContent className="flex flex-col gap-5">
|
||||||
<SettingsTabs storageChannels={storageChannels} settings={settings} />
|
<SettingsTabs storageChannels={storageChannels} notificationChannels={notificationChannels} settings={settings} />
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
|||||||
+4
@@ -8,6 +8,7 @@ import {getOrganizationProjectDatabases} from "@/lib/services";
|
|||||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||||
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
||||||
import {DatabaseContent} from "@/components/wrappers/dashboard/projects/database/database-content";
|
import {DatabaseContent} from "@/components/wrappers/dashboard/projects/database/database-content";
|
||||||
|
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{
|
export default async function RoutePage(props: PageParams<{
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -83,6 +84,8 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const databaseHealthLogs = dbItem ? await getHealthLast12hLogs({ id: dbItem.id }) : []
|
||||||
|
|
||||||
|
|
||||||
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
||||||
|
|
||||||
@@ -95,6 +98,7 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
activeMember={activeMember}
|
activeMember={activeMember}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
database={dbItem}
|
database={dbItem}
|
||||||
|
databaseHealthLogs={databaseHealthLogs}
|
||||||
isAlreadyRestore={isAlreadyRestore}
|
isAlreadyRestore={isAlreadyRestore}
|
||||||
restorations={restorations}
|
restorations={restorations}
|
||||||
backups={backups}
|
backups={backups}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { NextResponse } from "next/server";
|
import {NextResponse} from "next/server";
|
||||||
import { eq } from "drizzle-orm";
|
import {eq} from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
import {db} from "@/db";
|
||||||
import * as drizzleDb 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) {
|
export function withAgentCheck(handler: Function) {
|
||||||
return async (request: Request, context: { params: Promise<{ agentId: string }> }) => {
|
return async (request: Request, context: { params: Promise<{ agentId: string }> }) => {
|
||||||
@@ -14,23 +17,22 @@ export function withAgentCheck(handler: Function) {
|
|||||||
|
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Agent not found" },
|
{error: "Agent not found"},
|
||||||
{ status: 404 }
|
{status: 404}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return handler(request, { ...context, agent });
|
return handler(request, {...context, agent});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error in agent middleware:", err);
|
log.error({error: err, name: "withAgentCheck"}, "Error in agent middleware");
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Internal server error" },
|
{error: "Internal server error"},
|
||||||
{ status: 500 }
|
{status: 500}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function getDatabaseOrThrow(generatedId: string) {
|
export async function getDatabaseOrThrow(generatedId: string) {
|
||||||
const database = await db.query.database.findFirst({
|
const database = await db.query.database.findFirst({
|
||||||
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
|
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
|
||||||
@@ -43,8 +45,8 @@ export async function getDatabaseOrThrow(generatedId: string) {
|
|||||||
|
|
||||||
if (!database) {
|
if (!database) {
|
||||||
throw NextResponse.json(
|
throw NextResponse.json(
|
||||||
{ error: "Database associated with generatedId not found" },
|
{error: "Database associated with generatedId not found"},
|
||||||
{ status: 404 }
|
{status: 404}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import {withUpdatedAt} from "@/db/utils";
|
|||||||
import {eventEmitter} from "@/features/shared/event";
|
import {eventEmitter} from "@/features/shared/event";
|
||||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
||||||
import {EventKind} from "@/features/notifications/types";
|
import {EventKind} from "@/features/notifications/types";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
|
||||||
|
const log = logger.child({module: "api/agent/backup/route"});
|
||||||
|
|
||||||
export type BodyPost = {
|
export type BodyPost = {
|
||||||
method: "manual" | "automatic"
|
method: "manual" | "automatic"
|
||||||
@@ -77,7 +80,6 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
eventEmitter.emit('modification', {update: true});
|
eventEmitter.emit('modification', {update: true});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -88,7 +90,7 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
|||||||
{status: 200}
|
{status: 200}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in POST for INIT backup:", error);
|
log.error({error: error}, "Error in POST for INIT backup");
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: "Internal server error"},
|
{error: "Internal server error"},
|
||||||
{status: 500}
|
{status: 500}
|
||||||
@@ -102,7 +104,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
|||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
const body: BodyPatch = await request.json();
|
const body: BodyPatch = await request.json();
|
||||||
console.log("body", body);
|
log.debug({data: body}, "Body from PATH in backup route");
|
||||||
|
|
||||||
const status = body.status
|
const status = body.status
|
||||||
const backupId = body.backupId
|
const backupId = body.backupId
|
||||||
@@ -142,9 +144,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
|||||||
{status: 200}
|
{status: 200}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
log.error({error: error}, "Error in PATCH backup")
|
||||||
|
|
||||||
console.error("Error in PATCH backup:", error);
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: "Internal server error"},
|
{error: "Internal server error"},
|
||||||
{status: 500}
|
{status: 500}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import {db} from "@/db";
|
|||||||
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
|
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
|
||||||
import {isUuidv4} from "@/utils/verify-uuid";
|
import {isUuidv4} from "@/utils/verify-uuid";
|
||||||
import {eventEmitter} from "@/features/shared/event";
|
import {eventEmitter} from "@/features/shared/event";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
|
||||||
|
const log = logger.child({module: "api/agent/backup/upload/init"});
|
||||||
|
|
||||||
export type Body = {
|
export type Body = {
|
||||||
generatedId: string
|
generatedId: string
|
||||||
@@ -18,7 +21,7 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
|||||||
try {
|
try {
|
||||||
const body: Body = await request.json();
|
const body: Body = await request.json();
|
||||||
|
|
||||||
console.log("body", body);
|
log.debug({data: body}, "Body for backup upload init");
|
||||||
|
|
||||||
const generatedId = body.generatedId;
|
const generatedId = body.generatedId;
|
||||||
const storageChannelId = body.storageChannelId;
|
const storageChannelId = body.storageChannelId;
|
||||||
@@ -66,7 +69,7 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
|||||||
{status: 200}
|
{status: 200}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in POST for INIT backup:", error);
|
log.error({error: error}, "Error in POST for INIT backup");
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: "Internal server error"},
|
{error: "Internal server error"},
|
||||||
{status: 500}
|
{status: 500}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import {db as dbClient, db} from "@/db";
|
|||||||
import {withUpdatedAt} from "@/db/utils";
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
|
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
|
||||||
import {eventEmitter} from "@/features/shared/event";
|
import {eventEmitter} from "@/features/shared/event";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
|
||||||
|
const log = logger.child({module: "api/agent/backup/upload/status"});
|
||||||
|
|
||||||
export type Body = {
|
export type Body = {
|
||||||
generatedId: string
|
generatedId: string
|
||||||
@@ -27,8 +30,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
|||||||
const backupStorageId = body.backupStorageId;
|
const backupStorageId = body.backupStorageId;
|
||||||
const backupId = body.backupId;
|
const backupId = body.backupId;
|
||||||
|
|
||||||
|
log.debug({data: body}, "Body for backup upload status");
|
||||||
console.log("body", body);
|
|
||||||
|
|
||||||
const database = await getDatabaseOrThrow(generatedId);
|
const database = await getDatabaseOrThrow(generatedId);
|
||||||
|
|
||||||
@@ -85,7 +87,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
|||||||
{status: 200}
|
{status: 200}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in POST for INIT backup:", error);
|
log.error({error: error},"Error in POST for INIT backup");
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: "Internal server error"},
|
{error: "Internal server error"},
|
||||||
{status: 500}
|
{status: 500}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import * as drizzleDb from "@/db";
|
|||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {and, eq} from "drizzle-orm";
|
import {and, eq} from "drizzle-orm";
|
||||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
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 = {
|
export type BodyResultRestore = {
|
||||||
generatedId: string
|
generatedId: string
|
||||||
@@ -66,14 +68,13 @@ export async function POST(
|
|||||||
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
|
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
message: true,
|
status: true,
|
||||||
details: "Restoration successfully updated"
|
message: "Restoration successfully updated"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return Response.json(response, {status: 200})
|
return Response.json(response, {status: 200})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in POST handler:', error);
|
log.error({error: error}, "Error in POST handler")
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: 'Internal server error'},
|
{error: 'Internal server error'},
|
||||||
{status: 500}
|
{status: 500}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {NextResponse} from "next/server";
|
|||||||
import {Body} from "./route";
|
import {Body} from "./route";
|
||||||
import {isUuidv4} from "@/utils/verify-uuid";
|
import {isUuidv4} from "@/utils/verify-uuid";
|
||||||
import {Agent} from "@/db/schema/08_agent";
|
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 * as drizzleDb from "@/db";
|
||||||
import {db, db as dbClient} from "@/db";
|
import {db, db as dbClient} from "@/db";
|
||||||
import {and, eq, inArray} from "drizzle-orm";
|
import {and, eq, inArray} from "drizzle-orm";
|
||||||
@@ -11,6 +11,9 @@ import {withUpdatedAt} from "@/db/utils";
|
|||||||
import type {StorageInput} from "@/features/storages/types";
|
import type {StorageInput} from "@/features/storages/types";
|
||||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
import {dispatchStorage} from "@/features/storages/dispatch";
|
||||||
import {Setting} from "@/db/schema/01_setting";
|
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) {
|
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date, settings: Setting) {
|
||||||
const databasesResponse = [];
|
const databasesResponse = [];
|
||||||
@@ -56,7 +59,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!dbmsEnumSchema.safeParse(db.dbms).success) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,12 +70,24 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
name: db.name,
|
name: db.name,
|
||||||
dbms: db.dbms as EDbmsSchema,
|
dbms: db.dbms as EDbmsSchema,
|
||||||
agentDatabaseId: db.generatedId,
|
agentDatabaseId: db.generatedId,
|
||||||
lastContact: lastContact,
|
lastContact: db.pingStatus ? lastContact : null,
|
||||||
|
healthErrorCount: null
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
||||||
if (databaseCreated) {
|
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)
|
const storages = await getDatabaseStorageChannels(databaseCreated.id)
|
||||||
|
|
||||||
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null));
|
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({
|
.set(withUpdatedAt({
|
||||||
name: db.name,
|
name: db.name,
|
||||||
agentId: agent.id,
|
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))
|
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
|
||||||
.returning();
|
.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({
|
const activeBackup = await dbClient.query.backup.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
|
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));
|
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||||
|
|
||||||
const errorMessage = "Failed to get backup URL";
|
const errorMessage = "Failed to get backup URL";
|
||||||
console.error("Restoration failed: ", errorMessage);
|
log.error({error: errorMessage, name: "handleDatabases"}, "Restoration failed");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Restoration crashed unexpectedly:", err);
|
log.error({error: err, name: "handleDatabases"}, "Restoration crashed unexpectedly");
|
||||||
await dbClient
|
await dbClient
|
||||||
.update(drizzleDb.schemas.restoration)
|
.update(drizzleDb.schemas.restoration)
|
||||||
.set({status: "failed"})
|
.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));
|
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return databasesResponse;
|
return databasesResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,17 @@ import {EDbmsSchema} from "@/db/schema/types";
|
|||||||
import {eq} from "drizzle-orm";
|
import {eq} from "drizzle-orm";
|
||||||
import {isUuidv4} from "@/utils/verify-uuid";
|
import {isUuidv4} from "@/utils/verify-uuid";
|
||||||
import {withUpdatedAt} from "@/db/utils";
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
import {eventEmitter} from "@/features/shared/event";
|
import {logger} from "@/lib/logger";
|
||||||
import {notFound} from "next/navigation";
|
|
||||||
|
|
||||||
|
const log = logger.child({module: "api/agent/status/route"});
|
||||||
|
|
||||||
|
|
||||||
export type databaseAgent = {
|
export type databaseAgent = {
|
||||||
name: string,
|
name: string,
|
||||||
dbms: EDbmsSchema,
|
dbms: EDbmsSchema,
|
||||||
generatedId: string
|
generatedId: string
|
||||||
|
pingStatus: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Body = {
|
export type Body = {
|
||||||
@@ -27,14 +31,14 @@ export async function POST(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const agentId = (await params).agentId
|
const agentId = (await params).agentId
|
||||||
console.log(agentId)
|
log.debug(`Agent ID: ${agentId}`)
|
||||||
const body: Body = await request.json();
|
const body: Body = await request.json();
|
||||||
const lastContact = new Date();
|
const lastContact = new Date();
|
||||||
let message: string
|
let message: string
|
||||||
|
|
||||||
if (!isUuidv4(agentId)) {
|
if (!isUuidv4(agentId)) {
|
||||||
message = "agentId is not a valid uuid"
|
message = "agentId is not a valid uuid"
|
||||||
console.error(message)
|
log.error({error: message}, "An error occurred")
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: "agentId is not a valid uuid"},
|
{error: "agentId is not a valid uuid"},
|
||||||
{status: 500}
|
{status: 500}
|
||||||
@@ -55,17 +59,25 @@ export async function POST(
|
|||||||
return NextResponse.json({error: "An error occured"}, {status: 404})
|
return NextResponse.json({error: "An error occured"}, {status: 404})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const databasesResponse = await handleDatabases(body, agent, lastContact, settings)
|
const databasesResponse = await handleDatabases(body, agent, lastContact, settings)
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(drizzleDb.schemas.agent)
|
.update(drizzleDb.schemas.agent)
|
||||||
.set(withUpdatedAt({
|
.set(withUpdatedAt({
|
||||||
version: body.version,
|
version: body.version,
|
||||||
lastContact: lastContact
|
lastContact: lastContact,
|
||||||
|
healthErrorCount: null
|
||||||
}))
|
}))
|
||||||
.where(eq(drizzleDb.schemas.agent.id, agentId));
|
.where(eq(drizzleDb.schemas.agent.id, agentId));
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(drizzleDb.schemas.healthcheckLog)
|
||||||
|
.values({
|
||||||
|
kind: "agent",
|
||||||
|
status: "success",
|
||||||
|
objectId: agentId,
|
||||||
|
date: lastContact
|
||||||
|
})
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
agent: {
|
agent: {
|
||||||
@@ -75,10 +87,9 @@ export async function POST(
|
|||||||
databases: databasesResponse
|
databases: databasesResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return Response.json(response)
|
return Response.json(response)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in POST handler:', error);
|
log.error({error: error}, "Error in POST handler")
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: 'Internal server error'},
|
{error: 'Internal server error'},
|
||||||
{status: 500}
|
{status: 500}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import {auth} from "@/lib/auth/auth";
|
|||||||
import {headers} from "next/headers";
|
import {headers} from "next/headers";
|
||||||
import {NextResponse} from "next/server";
|
import {NextResponse} from "next/server";
|
||||||
import {eventEmitter} from "@/features/shared/event";
|
import {eventEmitter} from "@/features/shared/event";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
|
||||||
|
const log = logger.child({module: "api/events"});
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
|
||||||
@@ -16,9 +19,9 @@ export async function GET(request: Request) {
|
|||||||
return new Response(
|
return new Response(
|
||||||
new ReadableStream({
|
new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
console.log('Stream started');
|
log.info("Stream started");
|
||||||
const handleModification = (data: any) => {
|
const handleModification = (data: any) => {
|
||||||
console.log('Modification event triggered:', data);
|
log.info({data: data},"Modification event triggered");
|
||||||
controller.enqueue(`event: modification\n`);
|
controller.enqueue(`event: modification\n`);
|
||||||
controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);
|
controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);
|
||||||
};
|
};
|
||||||
@@ -26,7 +29,7 @@ export async function GET(request: Request) {
|
|||||||
eventEmitter.on('modification', handleModification);
|
eventEmitter.on('modification', handleModification);
|
||||||
|
|
||||||
request.signal.addEventListener('abort', () => {
|
request.signal.addEventListener('abort', () => {
|
||||||
console.log('Client disconnected');
|
log.info("Client disconnected");
|
||||||
controller.close();
|
controller.close();
|
||||||
eventEmitter.off('modification', handleModification);
|
eventEmitter.off('modification', handleModification);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import path from "path";
|
|||||||
import type {StorageInput} from "@/features/storages/types";
|
import type {StorageInput} from "@/features/storages/types";
|
||||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
import {dispatchStorage} from "@/features/storages/dispatch";
|
||||||
import {Readable} from "node:stream";
|
import {Readable} from "node:stream";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
|
||||||
|
const log = logger.child({module: "api/files/backups"});
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -30,7 +33,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
console.debug(input);
|
log.debug({input: input}, "Dispatch Storage");
|
||||||
|
|
||||||
const result = await dispatchStorage(input, undefined, storageId);
|
const result = await dispatchStorage(input, undefined, storageId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import {NextResponse} from "next/server";
|
import {NextResponse} from "next/server";
|
||||||
import {auth} from "@/lib/auth/auth";
|
import {auth} from "@/lib/auth/auth";
|
||||||
import {headers} from "next/headers";
|
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 {StorageInput} from "@/features/storages/types";
|
||||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
import {dispatchStorage} from "@/features/storages/dispatch";
|
||||||
import {Readable} from "node:stream";
|
import {Readable} from "node:stream";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
|
||||||
|
const log = logger.child({module: "api/files/images"});
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -56,7 +56,7 @@ export async function GET(
|
|||||||
const result = await dispatchStorage(input, undefined, storageId);
|
const result = await dispatchStorage(input, undefined, storageId);
|
||||||
|
|
||||||
if (!result.file || !(result.file instanceof Readable)) {
|
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(
|
return NextResponse.json(
|
||||||
{error: "Invalid file payload"},
|
{error: "Invalid file payload"},
|
||||||
{status: 500}
|
{status: 500}
|
||||||
@@ -82,7 +82,7 @@ export async function GET(
|
|||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error streaming image:", err);
|
log.error({error: err}, `Error streaming image`);
|
||||||
return NextResponse.json({error: "Error fetching file"}, {status: 500});
|
return NextResponse.json({error: "Error fetching file"}, {status: 500});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,9 @@ import {NextResponse} from "next/server";
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import {env} from "@/env.mjs";
|
import {env} from "@/env.mjs";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
|
||||||
|
const log = logger.child({module: "api/tus/hooks"});
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
@@ -13,7 +15,7 @@ export async function POST(request: Request) {
|
|||||||
const uploadOffset = headers["Upload-Offset"]?.[0];
|
const uploadOffset = headers["Upload-Offset"]?.[0];
|
||||||
const status = headers["X-Status"]?.[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 (status === "success") {
|
||||||
if (
|
if (
|
||||||
@@ -29,9 +31,7 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({error: "Missing X-File-Path"}, {status: 500});
|
return NextResponse.json({error: "Missing X-File-Path"}, {status: 500});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uploadDir = path.join(env.PRIVATE_PATH!, "/uploads/");
|
||||||
// const uploadDir = path.join(process.cwd(), "/private/uploads/");
|
|
||||||
const uploadDir = path.join(env.PRIVATE_PATH, "/uploads/");
|
|
||||||
|
|
||||||
const oldFilePath = path.join(uploadDir, "tmp", id);
|
const oldFilePath = path.join(uploadDir, "tmp", id);
|
||||||
const newFilePath = path.join(uploadDir, filePath);
|
const newFilePath = path.join(uploadDir, filePath);
|
||||||
@@ -74,7 +74,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
return NextResponse.json({});
|
return NextResponse.json({});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Hook error:", error);
|
log.error({error: error},"TUS Hook error");
|
||||||
return NextResponse.json({error: "Internal server error"}, {status: 500});
|
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 { cn } from "@/lib/utils";
|
||||||
import { Providers } from "./providers";
|
import { Providers } from "./providers";
|
||||||
|
|
||||||
const title = process.env.PROJECT_NAME ?? "App Title";
|
const title = process.env.PROJECT_NAME ?? "Portabase";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ services:
|
|||||||
# context: .
|
# context: .
|
||||||
# dockerfile: docker/dockerfile/Dockerfile
|
# dockerfile: docker/dockerfile/Dockerfile
|
||||||
# target: prod
|
# target: prod
|
||||||
image: portabase/portabase:1.4.0
|
image: portabase/portabase:1.7.1
|
||||||
ports:
|
ports:
|
||||||
- '8887:80'
|
- '8887:80'
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import {test, expect, Page} from '@playwright/test';
|
||||||
|
|
||||||
|
|
||||||
|
type UserCredentials = {
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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!'},
|
||||||
|
}
|
||||||
|
|
||||||
|
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"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
//TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.serial('User signup and login flows', () => {
|
||||||
|
|
||||||
|
test('Redirect to login if not connected', async ({page}) => {
|
||||||
|
await page.goto('/dashboard/projects');
|
||||||
|
await expect(page).toHaveURL("login?redirect=%2Fdashboard%2Fprojects");
|
||||||
|
});
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// test('Successful logout', async ({page}) => {
|
||||||
|
// await logout()
|
||||||
|
// await expect(page).toHaveURL('/login')
|
||||||
|
// })
|
||||||
@@ -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: {
|
typescript: {
|
||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: true,
|
||||||
},
|
},
|
||||||
|
logging: {
|
||||||
|
browserToTerminal: false,
|
||||||
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: "10gb",
|
bodySizeLimit: "10gb",
|
||||||
|
|||||||
+31
-28
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "portabase",
|
"name": "portabase",
|
||||||
"version": "1.4.22",
|
"version": "1.9.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 8887",
|
"dev": "next dev --turbopack -p 8887",
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
"release": "release-it"
|
"release": "release-it"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/passkey": "^1.4.19",
|
"@better-auth/passkey": "^1.5.6",
|
||||||
"@better-auth/sso": "^1.4.19",
|
"@better-auth/sso": "^1.5.6",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
@@ -47,20 +47,20 @@
|
|||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@react-email/components": "^0.0.41",
|
"@react-email/components": "^0.0.41",
|
||||||
"@t3-oss/env-nextjs": "^0.13.10",
|
"@t3-oss/env-nextjs": "^0.13.11",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.95.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/nodemailer": "^6.4.23",
|
"@types/nodemailer": "^6.4.23",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@zenstackhq/runtime": "2.14.2",
|
"@zenstackhq/runtime": "2.14.2",
|
||||||
"argon2": "^0.43.1",
|
"argon2": "^0.43.1",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-auth": "1.4.18",
|
"better-auth": "1.5.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dockerode": "^4.0.9",
|
"dockerode": "^4.0.10",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"drizzle-orm": "^0.43.1",
|
"drizzle-orm": "^0.43.1",
|
||||||
"drizzle-zod": "^0.7.1",
|
"drizzle-zod": "^0.7.1",
|
||||||
@@ -68,23 +68,25 @@
|
|||||||
"googleapis": "^170.1.0",
|
"googleapis": "^170.1.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"minio": "^8.0.6",
|
"minio": "^8.0.7",
|
||||||
"motion": "^12.34.3",
|
"motion": "^12.38.0",
|
||||||
"next": "16.1.5",
|
"next": "16.2.1",
|
||||||
"next-safe-action": "^7.10.8",
|
"next-safe-action": "^7.10.8",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.4.0",
|
||||||
"nodemailer": "^7.0.13",
|
"nodemailer": "^7.0.13",
|
||||||
"npm-check-updates": "^18.3.1",
|
"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",
|
"prettier": "^3.8.1",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-day-picker": "9.7.0",
|
"react-day-picker": "9.7.0",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-dropzone": "^14.4.1",
|
"react-dropzone": "^14.4.1",
|
||||||
"react-email": "^4.3.2",
|
"react-email": "^4.3.2",
|
||||||
"react-hook-form": "^7.71.2",
|
"react-hook-form": "^7.72.0",
|
||||||
"react-qr-code": "^2.0.18",
|
"react-qr-code": "^2.0.18",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-twc": "^1.5.1",
|
"react-twc": "^1.5.1",
|
||||||
@@ -94,42 +96,43 @@
|
|||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"swiper": "^12.1.2",
|
"swiper": "^12.1.3",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.20.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/react": "^6.0.2",
|
"@iconify/react": "^6.0.2",
|
||||||
|
"@playwright/test": "1.58.2",
|
||||||
"@react-email/preview-server": "4.3.2",
|
"@react-email/preview-server": "4.3.2",
|
||||||
"@react-email/render": "^2.0.4",
|
"@react-email/render": "^2.0.4",
|
||||||
"@release-it/bumper": "^7.0.5",
|
"@release-it/bumper": "^7.0.5",
|
||||||
"@release-it/conventional-changelog": "^10.0.5",
|
"@release-it/conventional-changelog": "^10.0.6",
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||||
"@types/node": "^22.19.11",
|
"@types/node": "^22.19.15",
|
||||||
"@types/node-forge": "^1.3.14",
|
"@types/node-forge": "^1.3.14",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@zenstackhq/openapi": "^2.22.1",
|
"@zenstackhq/openapi": "^2.22.1",
|
||||||
"@zenstackhq/tanstack-query": "^2.22.2",
|
"@zenstackhq/tanstack-query": "^2.22.2",
|
||||||
"baseline-browser-mapping": "^2.10.0",
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.10",
|
||||||
"esbuild": "^0.27.3",
|
"esbuild": "^0.27.4",
|
||||||
"eslint": "^9.39.3",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "^16.1.6",
|
"eslint-config-next": "^16.2.1",
|
||||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
"eslint-plugin-tailwindcss": "^3.18.2",
|
||||||
"framer-motion": "^12.34.3",
|
"framer-motion": "^12.38.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.8",
|
||||||
"release-it": "^19.2.4",
|
"release-it": "^19.2.4",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.2",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"zenstack": "2.14.2"
|
"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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read environment variables from file.
|
||||||
|
* https://github.com/motdotla/dotenv
|
||||||
|
*/
|
||||||
|
// import dotenv from 'dotenv';
|
||||||
|
// import path from 'path';
|
||||||
|
// 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: {
|
||||||
|
/* Base URL to use in actions like `await page.goto('')`. */
|
||||||
|
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 for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: { ...devices['Desktop Firefox'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'webkit',
|
||||||
|
use: { ...devices['Desktop Safari'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Test against mobile viewports. */
|
||||||
|
{
|
||||||
|
name: 'Mobile Chrome',
|
||||||
|
use: { ...devices['Pixel 5'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Mobile Safari',
|
||||||
|
use: { ...devices['iPhone 12'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Test against branded browsers. */
|
||||||
|
// {
|
||||||
|
// name: 'Microsoft Edge',
|
||||||
|
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Google Chrome',
|
||||||
|
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
// webServer: {
|
||||||
|
// command: 'docker compose up -d && make up',
|
||||||
|
// url: 'http://localhost:8887',
|
||||||
|
// reuseExistingServer: !process.env.CI,
|
||||||
|
// },
|
||||||
|
});
|
||||||
Generated
+1411
-1242
File diff suppressed because it is too large
Load Diff
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,11 +209,22 @@ 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>({
|
const useZodForm = <Z extends ZodSchema>({
|
||||||
schema,
|
schema,
|
||||||
...formProps
|
...formProps
|
||||||
}: UseZodFormProps<Z>) =>
|
}: UseZodFormProps<Z>): UseFormReturn<TypeOf<Z>> =>
|
||||||
useForm({
|
useForm<TypeOf<Z>>({
|
||||||
...formProps,
|
...formProps,
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
|
|||||||
+5
-1
@@ -6,6 +6,10 @@ export const S3ChannelConfigSchema = z.object({
|
|||||||
accessKey: z.string().min(1, "Access Key is required"),
|
accessKey: z.string().min(1, "Access Key is required"),
|
||||||
secretKey: z.string().min(1, "Secret Key is required"),
|
secretKey: z.string().min(1, "Secret Key is required"),
|
||||||
bucketName: z.string().min(1, "Bucket name 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),
|
ssl: z.boolean().optional().default(true),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {Card, CardContent} from "@/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
FormDescription,
|
||||||
|
|||||||
+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>;
|
||||||
@@ -7,14 +7,20 @@ import {Setting} from "@/db/schema/01_setting";
|
|||||||
import {SettingsEmailSection} from "@/components/wrappers/dashboard/admin/settings/email/settings-email-section";
|
import {SettingsEmailSection} from "@/components/wrappers/dashboard/admin/settings/email/settings-email-section";
|
||||||
import {SettingsStorageSection} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage-section";
|
import {SettingsStorageSection} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage-section";
|
||||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||||
import {MailboxIcon, Save} from "lucide-react";
|
import {AlarmClock, MailboxIcon, Save} from "lucide-react";
|
||||||
|
import {
|
||||||
|
SettingsNotificationSection
|
||||||
|
} from "@/components/wrappers/dashboard/admin/settings/notification/settings-notification-section";
|
||||||
|
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||||
|
|
||||||
export type SettingsTabsProps = {
|
export type SettingsTabsProps = {
|
||||||
settings: Setting
|
settings: Setting
|
||||||
storageChannels: StorageChannelWith[]
|
storageChannels: StorageChannelWith[],
|
||||||
|
notificationChannels: NotificationChannelWith[];
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SettingsTabs = ({settings, storageChannels}: SettingsTabsProps) => {
|
export const SettingsTabs = ({settings, storageChannels, notificationChannels}: SettingsTabsProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
@@ -47,6 +53,15 @@ export const SettingsTabs = ({settings, storageChannels}: SettingsTabsProps) =>
|
|||||||
content: (
|
content: (
|
||||||
<SettingsStorageSection storageChannels={storageChannels} settings={settings}/>
|
<SettingsStorageSection storageChannels={storageChannels} settings={settings}/>
|
||||||
|
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Notification',
|
||||||
|
value: 'notification',
|
||||||
|
icon: AlarmClock,
|
||||||
|
content: (
|
||||||
|
<SettingsNotificationSection notificationChannels={notificationChannels} settings={settings}/>
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+1
-1
@@ -109,7 +109,7 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="storageChannelId"
|
name="storageChannelId"
|
||||||
render={({field}) => (
|
render={({field}) => (
|
||||||
<FormItem className="flex-grow min-w-[200px] sm:flex-grow-0 sm:w-64">
|
<FormItem className="flex-grow">
|
||||||
<FormLabel>Default Storage Provider</FormLabel>
|
<FormLabel>Default Storage Provider</FormLabel>
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
<SelectTrigger className="w-full h-full mb-0">
|
<SelectTrigger className="w-full h-full mb-0">
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ export const AgentCard = (props: agentCardProps) => {
|
|||||||
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-bold tracking-wider">
|
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-bold tracking-wider">
|
||||||
v{agent.version}
|
v{agent.version}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{agent.healthErrorCount && (
|
||||||
|
<Badge variant="destructive" className="h-5 px-1.5 text-[10px] font-bold tracking-wider">
|
||||||
|
down
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{isUpdateAvailable && (
|
{isUpdateAvailable && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {Separator} from "@/components/ui/separator";
|
|||||||
import {Badge} from "@/components/ui/badge";
|
import {Badge} from "@/components/ui/badge";
|
||||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||||
import {AgentDatabaseCard} from "@/components/wrappers/dashboard/agent/agent-database-card";
|
import {AgentDatabaseCard} from "@/components/wrappers/dashboard/agent/agent-database-card";
|
||||||
|
import {HealthCheckGraph} from "@/components/wrappers/dashboard/health/heath-grid";
|
||||||
|
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
|
||||||
|
|
||||||
type AgentContentPageProps = {
|
type AgentContentPageProps = {
|
||||||
edgeKey: string;
|
edgeKey: string;
|
||||||
@@ -32,7 +34,8 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
|
|||||||
return result?.data;
|
return result?.data;
|
||||||
},
|
},
|
||||||
initialData: {
|
initialData: {
|
||||||
data: initialAgent
|
data: initialAgent,
|
||||||
|
health: []
|
||||||
},
|
},
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
gcTime: 0,
|
gcTime: 0,
|
||||||
@@ -40,13 +43,15 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
|
|||||||
});
|
});
|
||||||
|
|
||||||
const agent = data?.data ?? initialAgent;
|
const agent = data?.data ?? initialAgent;
|
||||||
|
const agentHealthLogs: HealthcheckLog[] = data?.health ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
<div className="flex flex-col sm:flex-row sm:justify-between gap-6 ">
|
<div className="flex flex-col sm:flex-row sm:justify-between gap-6 ">
|
||||||
<Card className="w-full sm:w-auto flex-1 border-none shadow-none bg-muted/30">
|
<Card className="w-full sm:w-auto flex-1 transition-all border-border/50 bg-card ">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Databases</CardTitle>
|
<CardTitle
|
||||||
|
className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Databases</CardTitle>
|
||||||
<Server className="h-4 w-4 text-muted-foreground opacity-50"/>
|
<Server className="h-4 w-4 text-muted-foreground opacity-50"/>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -55,26 +60,34 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="w-full sm:w-auto flex-1 border-none shadow-none bg-muted/30">
|
<Card className="w-full sm:w-auto flex-1 transition-all border-border/50 bg-card ">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Last contact</CardTitle>
|
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Last
|
||||||
|
contact</CardTitle>
|
||||||
<Server className="h-4 w-4 text-muted-foreground opacity-50"/>
|
<Server className="h-4 w-4 text-muted-foreground opacity-50"/>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-3xl font-bold tracking-tight">{formatDateLastContact(agent.lastContact)}</div>
|
<div
|
||||||
|
className="text-3xl font-bold tracking-tight">{formatDateLastContact(agent.lastContact)}</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">Status heartbeat</p>
|
<p className="text-xs text-muted-foreground mt-1">Status heartbeat</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{agent.lastContact && (
|
||||||
|
<HealthCheckGraph logs={agentHealthLogs}/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Accordion type="single" collapsible defaultValue={!agent.lastContact ? "registration" : undefined}>
|
<Accordion type="single" collapsible defaultValue={!agent.lastContact ? "registration" : undefined}>
|
||||||
<AccordionItem value="registration" className="border rounded-xl px-6 bg-card shadow-sm overflow-hidden transition-all duration-300">
|
<AccordionItem value="registration"
|
||||||
|
className="border rounded-xl px-6 bg-card shadow-sm overflow-hidden transition-all duration-300">
|
||||||
<AccordionTrigger className="hover:no-underline py-4 group">
|
<AccordionTrigger className="hover:no-underline py-4 group">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xl font-bold tracking-tight">Registration & Setup</span>
|
<span className="text-xl font-bold tracking-tight">Registration & Setup</span>
|
||||||
{!agent.lastContact && (
|
{!agent.lastContact && (
|
||||||
<Badge variant="outline" className="bg-orange-500/10 text-orange-600 border-orange-500/20 animate-pulse">
|
<Badge variant="outline"
|
||||||
|
className="bg-orange-500/10 text-orange-600 border-orange-500/20 animate-pulse">
|
||||||
Action Required
|
Action Required
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -100,7 +113,7 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="opacity-50" />
|
<Separator className="opacity-50"/>
|
||||||
<CardsWithPagination
|
<CardsWithPagination
|
||||||
cardsPerPage={4}
|
cardsPerPage={4}
|
||||||
numberOfColumns={2}
|
numberOfColumns={2}
|
||||||
|
|||||||
@@ -21,14 +21,14 @@ interface DatabaseActionsCellProps {
|
|||||||
backup: BackupWith;
|
backup: BackupWith;
|
||||||
activeMember: MemberWithUser;
|
activeMember: MemberWithUser;
|
||||||
isAlreadyRestore: boolean;
|
isAlreadyRestore: boolean;
|
||||||
|
isBackupOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DatabaseActionsCell({backup, activeMember, isAlreadyRestore}: DatabaseActionsCellProps) {
|
export function DatabaseActionsCell({backup, activeMember, isAlreadyRestore, isBackupOnly}: DatabaseActionsCellProps) {
|
||||||
const {openModal} = useBackupModal();
|
const {openModal} = useBackupModal();
|
||||||
|
|
||||||
if (backup.deletedAt || activeMember.role === "member") return null;
|
if (backup.deletedAt || activeMember.role === "member") return null;
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex items-center space-x-2")}>
|
<div className={cn("flex items-center space-x-2")}>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -40,9 +40,9 @@ export function DatabaseActionsCell({backup, activeMember, isAlreadyRestore}: Da
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
|
||||||
{backup.status == "success" ? (
|
{backup.status == "success" ? (
|
||||||
<>
|
<>
|
||||||
|
{!isBackupOnly && (
|
||||||
<TooltipCustom disabled={isAlreadyRestore}
|
<TooltipCustom disabled={isAlreadyRestore}
|
||||||
text="Already a restoration waiting">
|
text="Already a restoration waiting">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -52,6 +52,7 @@ export function DatabaseActionsCell({backup, activeMember, isAlreadyRestore}: Da
|
|||||||
<ReloadIcon/> Restore
|
<ReloadIcon/> Restore
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</TooltipCustom>
|
</TooltipCustom>
|
||||||
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() => openModal("download", backup)}
|
onSelect={() => openModal("download", backup)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as drizzleDb from "@/db";
|
|||||||
import {BackupWith, Restoration} from "@/db/schema/07_database";
|
import {BackupWith, Restoration} from "@/db/schema/07_database";
|
||||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
||||||
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
||||||
|
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
|
||||||
|
|
||||||
export const getDatabaseDataAction = userAction
|
export const getDatabaseDataAction = userAction
|
||||||
.schema(
|
.schema(
|
||||||
@@ -50,7 +51,9 @@ export const getDatabaseDataAction = userAction
|
|||||||
const successfulBackups = backups.filter(b => b.status === "success").length;
|
const successfulBackups = backups.filter(b => b.status === "success").length;
|
||||||
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
let activeOrganizationChannels = [];
|
let activeOrganizationChannels = [];
|
||||||
|
// @ts-ignore
|
||||||
let activeOrganizationStorageChannels = [];
|
let activeOrganizationStorageChannels = [];
|
||||||
|
|
||||||
if (database?.project?.organizationId) {
|
if (database?.project?.organizationId) {
|
||||||
@@ -61,16 +64,20 @@ export const getDatabaseDataAction = userAction
|
|||||||
activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled);
|
activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
database,
|
database,
|
||||||
backups,
|
backups,
|
||||||
restorations,
|
restorations,
|
||||||
|
// @ts-ignore
|
||||||
activeOrganizationChannels,
|
activeOrganizationChannels,
|
||||||
|
// @ts-ignore
|
||||||
activeOrganizationStorageChannels,
|
activeOrganizationStorageChannels,
|
||||||
stats: {
|
stats: {
|
||||||
totalBackups,
|
totalBackups,
|
||||||
availableBackups,
|
availableBackups,
|
||||||
successRate
|
successRate
|
||||||
}
|
},
|
||||||
|
health: database ? await getHealthLast12hLogs({ id: database.id }) : []
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||||
import {StorageChannel} from "@/db/schema/12_storage-channel";
|
import {StorageChannel} from "@/db/schema/12_storage-channel";
|
||||||
import {
|
import {
|
||||||
|
EVENT_KIND_BACKUP_ONLY_OPTIONS,
|
||||||
EVENT_KIND_OPTIONS,
|
EVENT_KIND_OPTIONS,
|
||||||
PoliciesSchema,
|
PoliciesSchema,
|
||||||
PoliciesType,
|
PoliciesType,
|
||||||
@@ -31,11 +32,11 @@ import {
|
|||||||
createAlertPoliciesAction, createStoragePoliciesAction, deleteAlertPoliciesAction, deleteStoragePoliciesAction,
|
createAlertPoliciesAction, createStoragePoliciesAction, deleteAlertPoliciesAction, deleteStoragePoliciesAction,
|
||||||
updateAlertPoliciesAction, updateStoragePoliciesAction
|
updateAlertPoliciesAction, updateStoragePoliciesAction
|
||||||
} from "@/components/wrappers/dashboard/database/channels-policy/policy.action";
|
} from "@/components/wrappers/dashboard/database/channels-policy/policy.action";
|
||||||
|
import {backupOnly} from "@/components/wrappers/dashboard/projects/database/database-tabs";
|
||||||
|
|
||||||
type ChannelPoliciesFormProps = {
|
type ChannelPoliciesFormProps = {
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
channels: NotificationChannel[] | StorageChannel[];
|
channels: NotificationChannel[] | StorageChannel[];
|
||||||
organizationId: string;
|
|
||||||
database: DatabaseWith;
|
database: DatabaseWith;
|
||||||
kind: ChannelKind
|
kind: ChannelKind
|
||||||
};
|
};
|
||||||
@@ -44,7 +45,6 @@ type ChannelPoliciesFormProps = {
|
|||||||
export const ChannelPoliciesForm = ({
|
export const ChannelPoliciesForm = ({
|
||||||
database,
|
database,
|
||||||
channels,
|
channels,
|
||||||
organizationId,
|
|
||||||
onSuccess,
|
onSuccess,
|
||||||
kind
|
kind
|
||||||
}: ChannelPoliciesFormProps) => {
|
}: ChannelPoliciesFormProps) => {
|
||||||
@@ -53,6 +53,9 @@ export const ChannelPoliciesForm = ({
|
|||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const channelText = getChannelTextBasedOnKind(kind);
|
const channelText = getChannelTextBasedOnKind(kind);
|
||||||
|
|
||||||
|
const isBackupOnly = backupOnly.some((type) => database.dbms === type);
|
||||||
|
|
||||||
|
|
||||||
const organizationChannels = channels.map(c => c.id);
|
const organizationChannels = channels.map(c => c.id);
|
||||||
|
|
||||||
const filterByChannel = <T, K extends keyof T>(
|
const filterByChannel = <T, K extends keyof T>(
|
||||||
@@ -300,7 +303,7 @@ export const ChannelPoliciesForm = ({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="max-w-full overflow-hidden">
|
<div className="max-w-full overflow-hidden">
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
options={EVENT_KIND_OPTIONS}
|
options={isBackupOnly ? EVENT_KIND_BACKUP_ONLY_OPTIONS : EVENT_KIND_OPTIONS}
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
defaultValue={field.value ?? []}
|
defaultValue={field.value ?? []}
|
||||||
placeholder={isMobile ? "Select events..." : "Select events to trigger notifications..."}
|
placeholder={isMobile ? "Select events..." : "Select events to trigger notifications..."}
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export const ChannelPoliciesModal = ({icon, kind, database, channels, organizati
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
<Separator className="mt-3 mb-3"/>
|
<Separator className="mt-3 mb-3"/>
|
||||||
<ChannelPoliciesForm
|
<ChannelPoliciesForm
|
||||||
organizationId={organizationId}
|
|
||||||
channels={channels}
|
channels={channels}
|
||||||
database={database}
|
database={database}
|
||||||
onSuccess={() => setOpen(false)}
|
onSuccess={() => setOpen(false)}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {z} from "zod";
|
|||||||
export const PolicySchema = z.object({
|
export const PolicySchema = z.object({
|
||||||
channelId: z.string().min(1, "Please select channel"),
|
channelId: z.string().min(1, "Please select channel"),
|
||||||
eventKinds: z.array(z.enum([
|
eventKinds: z.array(z.enum([
|
||||||
'error_backup', 'error_restore', 'success_restore', 'success_backup', 'weekly_report'
|
'error_backup', 'error_restore', 'success_restore', 'success_backup', 'weekly_report', 'error_health_database'
|
||||||
]))
|
]))
|
||||||
.optional(),
|
.optional(),
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
@@ -17,11 +17,16 @@ export const PoliciesSchema = z.object({
|
|||||||
export type PoliciesType = z.infer<typeof PoliciesSchema>;
|
export type PoliciesType = z.infer<typeof PoliciesSchema>;
|
||||||
export type PolicyType = z.infer<typeof PolicySchema>;
|
export type PolicyType = z.infer<typeof PolicySchema>;
|
||||||
|
|
||||||
|
export const EVENT_KIND_BACKUP_ONLY_OPTIONS = [
|
||||||
|
{label: "Error Backup", value: "error_backup"},
|
||||||
|
{label: "Success Backup", value: "success_backup"},
|
||||||
|
{label: "Health Ping Fail", value: "error_health_database"},
|
||||||
|
];
|
||||||
|
|
||||||
export const EVENT_KIND_OPTIONS = [
|
export const EVENT_KIND_OPTIONS = [
|
||||||
{label: "Error Backup", value: "error_backup"},
|
...EVENT_KIND_BACKUP_ONLY_OPTIONS,
|
||||||
{label: "Error Restore", value: "error_restore"},
|
{label: "Error Restore", value: "error_restore"},
|
||||||
{label: "Success Restore", value: "success_restore"},
|
{label: "Success Restore", value: "success_restore"},
|
||||||
{label: "Success Backup", value: "success_backup"},
|
// {label: "Weekly Report", value: "weekly_report"},
|
||||||
{label: "Weekly Report", value: "weekly_report"},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {useState} from "react";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {DatabaseWith} from "@/db/schema/07_database";
|
||||||
|
import {HeartPulse} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
|
||||||
|
import {HealthCheckGraph} from "@/components/wrappers/dashboard/health/heath-grid";
|
||||||
|
|
||||||
|
type HealthModalProps = {
|
||||||
|
database: DatabaseWith,
|
||||||
|
healthLogs: HealthcheckLog[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HealthModal = ({database, healthLogs}: HealthModalProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(true)}>
|
||||||
|
<HeartPulse/>
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="bottom">
|
||||||
|
<div className="flex pl-5 pt-4">
|
||||||
|
<SheetTitle>
|
||||||
|
Database Health Status
|
||||||
|
</SheetTitle>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pb-5">
|
||||||
|
<HealthCheckGraph logs={healthLogs} />
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {useMemo} from "react"
|
||||||
|
import {Card} from "@/components/ui/card"
|
||||||
|
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log"
|
||||||
|
import {useIsMobile} from "@/hooks/use-mobile";
|
||||||
|
|
||||||
|
type HealthStatus = "healthy" | "degraded" | "down" | "unknown"
|
||||||
|
|
||||||
|
interface HealthCheckData {
|
||||||
|
timestamp: Date
|
||||||
|
status: HealthStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
logs: HealthcheckLog[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTERVAL_MINUTES = 10
|
||||||
|
const WINDOW_HOURS = 12
|
||||||
|
|
||||||
|
function roundDateToInterval(date: Date, intervalMinutes: number): Date {
|
||||||
|
const ms = intervalMinutes * 60 * 1000
|
||||||
|
return new Date(Math.floor(date.getTime() / ms) * ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTimeSeries(logs: HealthcheckLog[]): HealthCheckData[] {
|
||||||
|
const intervalMs = INTERVAL_MINUTES * 60 * 1000
|
||||||
|
const now = new Date()
|
||||||
|
const roundedNow = roundDateToInterval(now, INTERVAL_MINUTES)
|
||||||
|
|
||||||
|
const buckets = (WINDOW_HOURS * 60) / INTERVAL_MINUTES
|
||||||
|
const data: HealthCheckData[] = []
|
||||||
|
const oldestLog = logs.length > 0 ? getOldestLog(logs) : null
|
||||||
|
for (let i = buckets - 1; i >= 0; i--) {
|
||||||
|
const start = new Date(roundedNow.getTime() - i * intervalMs)
|
||||||
|
const end = new Date(start.getTime() + intervalMs)
|
||||||
|
|
||||||
|
const bucketLogs = logs.filter(
|
||||||
|
(l) =>
|
||||||
|
l.date &&
|
||||||
|
new Date(l.date) >= start &&
|
||||||
|
new Date(l.date) < end
|
||||||
|
)
|
||||||
|
|
||||||
|
let status: HealthStatus = "unknown"
|
||||||
|
|
||||||
|
if (!oldestLog || new Date(oldestLog.date!) > start) {
|
||||||
|
status = "unknown"
|
||||||
|
} else if (new Date(oldestLog.date!) < start) {
|
||||||
|
status = "down"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bucketLogs.length > 0) {
|
||||||
|
const hasFailure = bucketLogs.some((l) => l.status === "failed")
|
||||||
|
const hasSuccess = bucketLogs.some((l) => l.status === "success")
|
||||||
|
|
||||||
|
if (hasFailure && hasSuccess) {
|
||||||
|
status = "degraded"
|
||||||
|
} else if (hasFailure) {
|
||||||
|
status = "down"
|
||||||
|
} else if (hasSuccess) {
|
||||||
|
status = "healthy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.push({timestamp: start, status})
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status: HealthStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case "healthy":
|
||||||
|
return "bg-emerald-500"
|
||||||
|
case "degraded":
|
||||||
|
return "bg-emerald-700"
|
||||||
|
case "down":
|
||||||
|
return "bg-red-500"
|
||||||
|
case "unknown":
|
||||||
|
return "bg-zinc-700"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOldestLog(logs: HealthcheckLog[]): HealthcheckLog {
|
||||||
|
console.log(logs)
|
||||||
|
const validLogs = logs.filter(l => l.date)
|
||||||
|
|
||||||
|
return validLogs.reduce((oldest, current) =>
|
||||||
|
new Date(current.date!) < new Date(oldest.date!) ? current : oldest
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(date: Date): string {
|
||||||
|
return date.toLocaleTimeString("en-US", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const HealthCheckGraph = ({logs}: Props) => {
|
||||||
|
const data = useMemo(() => {
|
||||||
|
return buildTimeSeries(logs)
|
||||||
|
}, [logs])
|
||||||
|
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
|
|
||||||
|
const hourLabels = useMemo(() => {
|
||||||
|
if (data.length === 0) return []
|
||||||
|
|
||||||
|
|
||||||
|
return data
|
||||||
|
.map((item, index) => ({ item, index }))
|
||||||
|
.filter(({ item }) => {
|
||||||
|
const hours = item.timestamp.getHours()
|
||||||
|
const minutes = item.timestamp.getMinutes()
|
||||||
|
if (isMobile) {
|
||||||
|
return minutes === 0 && hours % 3 === 0
|
||||||
|
} else {
|
||||||
|
return minutes === 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(({ item, index }) => ({
|
||||||
|
hour: formatTime(item.timestamp),
|
||||||
|
index,
|
||||||
|
}))
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const healthyCount = data.filter((d) => d.status === "healthy").length
|
||||||
|
|
||||||
|
const uptimePercent =
|
||||||
|
data.length > 0
|
||||||
|
? ((healthyCount / data.length) * 100).toFixed(1)
|
||||||
|
: "0.0"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="w-full">
|
||||||
|
<Card className="h-full flex flex-col p-4 border-border/50 bg-card gap-0">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Health</h2>
|
||||||
|
<p className="text-zinc-500 text-sm">
|
||||||
|
Last 12 hours • {INTERVAL_MINUTES} minute intervals
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-emerald-400 text-2xl font-bold">
|
||||||
|
{uptimePercent}%
|
||||||
|
</p>
|
||||||
|
<p className="text-zinc-500 text-sm">Uptime</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="relative mb-1 h-4 text-xs text-zinc-500">
|
||||||
|
{hourLabels.map((label, i) => {
|
||||||
|
const left = (label.index / (data.length - 1)) * 100
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="absolute -translate-x-1/2 whitespace-nowrap"
|
||||||
|
style={{ left: `${left}%` }}
|
||||||
|
>
|
||||||
|
{label.hour}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{data.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`flex-1 h-8 rounded-sm ${getStatusColor(item.status)} hover:ring-2 hover:ring-zinc-400 transition-all cursor-pointer`}
|
||||||
|
title={`${formatTime(item.timestamp)} - ${item.status}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-4 mt-4 text-xs text-zinc-500">
|
||||||
|
<Legend color="bg-zinc-700" label="Unknown"/>
|
||||||
|
<Legend color="bg-red-500" label="Down"/>
|
||||||
|
<Legend color="bg-emerald-700" label="Degraded"/>
|
||||||
|
<Legend color="bg-emerald-500" label="Healthy"/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Legend = ({color, label}: { color: string; label: string }) => (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className={`w-3 h-3 rounded-sm ${color}`}/>
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
@@ -20,6 +20,8 @@ import { ChannelPoliciesModal } from "@/components/wrappers/dashboard/database/c
|
|||||||
import { HardDrive, Megaphone } from "lucide-react";
|
import { HardDrive, Megaphone } from "lucide-react";
|
||||||
import { ImportModal } from "@/components/wrappers/dashboard/database/import/import-modal";
|
import { ImportModal } from "@/components/wrappers/dashboard/database/import/import-modal";
|
||||||
import { BackupButton } from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
|
import { BackupButton } from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
|
||||||
|
import {HealthModal} from "@/components/wrappers/dashboard/database/health/health-modal";
|
||||||
|
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
|
||||||
|
|
||||||
export type DatabaseContentProps = {
|
export type DatabaseContentProps = {
|
||||||
settings: Setting;
|
settings: Setting;
|
||||||
@@ -34,6 +36,7 @@ export type DatabaseContentProps = {
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
activeOrganizationChannels: any[];
|
activeOrganizationChannels: any[];
|
||||||
activeOrganizationStorageChannels: any[];
|
activeOrganizationStorageChannels: any[];
|
||||||
|
databaseHealthLogs: HealthcheckLog[]
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DatabaseContent = (props: DatabaseContentProps) => {
|
export const DatabaseContent = (props: DatabaseContentProps) => {
|
||||||
@@ -64,6 +67,7 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
|
|||||||
availableBackups: props.availableBackups,
|
availableBackups: props.availableBackups,
|
||||||
successRate: props.successRate,
|
successRate: props.successRate,
|
||||||
},
|
},
|
||||||
|
health: props.databaseHealthLogs
|
||||||
},
|
},
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
gcTime: 0,
|
gcTime: 0,
|
||||||
@@ -118,10 +122,13 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
|
|||||||
organizationId={props.organizationId}
|
organizationId={props.organizationId}
|
||||||
/>
|
/>
|
||||||
<ImportModal database={database} />
|
<ImportModal database={database} />
|
||||||
|
<HealthModal database={database} healthLogs={data?.health ?? []}/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BackupButton
|
<BackupButton
|
||||||
disable={isAlreadyBackup}
|
disable={isAlreadyBackup || !database.lastContact}
|
||||||
databaseId={database.id}
|
databaseId={database.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export type DatabaseTabsProps = {
|
|||||||
activeMember: MemberWithUser
|
activeMember: MemberWithUser
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const backupOnly = ["redis", "valkey"];
|
||||||
|
|
||||||
export const DatabaseTabs = (props: DatabaseTabsProps) => {
|
export const DatabaseTabs = (props: DatabaseTabsProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -26,6 +28,7 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newTab = searchParams.get("tab") ?? "backup";
|
const newTab = searchParams.get("tab") ?? "backup";
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setTab(newTab);
|
setTab(newTab);
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
@@ -33,7 +36,21 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => {
|
|||||||
router.push(`?tab=${value}`);
|
router.push(`?tab=${value}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const isBackupOnly = backupOnly.some((type) => props.database.dbms === type)
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{isBackupOnly ?
|
||||||
|
<DatabaseBackupList
|
||||||
|
isAlreadyRestore={props.isAlreadyRestore}
|
||||||
|
settings={props.settings}
|
||||||
|
database={props.database}
|
||||||
|
backups={props.backups}
|
||||||
|
activeMember={props.activeMember}
|
||||||
|
/>
|
||||||
|
:
|
||||||
<Tabs className="flex flex-col flex-1" value={tab} onValueChange={handleChangeTab}>
|
<Tabs className="flex flex-col flex-1" value={tab} onValueChange={handleChangeTab}>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="backup">Backup</TabsTrigger>
|
<TabsTrigger value="backup">Backup</TabsTrigger>
|
||||||
@@ -57,5 +74,9 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => {
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
}
|
||||||
|
|
||||||
|
</>
|
||||||
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+9
-4
@@ -15,6 +15,9 @@ import * as notificationLog from "./schema/11_notification-log";
|
|||||||
import * as storageChannel from "./schema/12_storage-channel";
|
import * as storageChannel from "./schema/12_storage-channel";
|
||||||
import * as storagePolicy from "@/db/schema/13_storage-policy";
|
import * as storagePolicy from "@/db/schema/13_storage-policy";
|
||||||
import * as backupStorage from "@/db/schema/14_storage-backup";
|
import * as backupStorage from "@/db/schema/14_storage-backup";
|
||||||
|
import * as healthcheckLog from "@/db/schema/15_healthcheck-log";
|
||||||
|
|
||||||
|
const log = logger.child({module: "db"});
|
||||||
|
|
||||||
|
|
||||||
import {Pool} from "pg";
|
import {Pool} from "pg";
|
||||||
@@ -22,6 +25,7 @@ import {Pool} from "pg";
|
|||||||
// Do not delete
|
// Do not delete
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import {migrate} from "drizzle-orm/node-postgres/migrator";
|
import {migrate} from "drizzle-orm/node-postgres/migrator";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
|
||||||
dotenv.config({
|
dotenv.config({
|
||||||
path: ".env",
|
path: ".env",
|
||||||
@@ -46,7 +50,8 @@ export const schemas = {
|
|||||||
...notificationLog,
|
...notificationLog,
|
||||||
...storageChannel,
|
...storageChannel,
|
||||||
...storagePolicy,
|
...storagePolicy,
|
||||||
...backupStorage
|
...backupStorage,
|
||||||
|
...healthcheckLog
|
||||||
};
|
};
|
||||||
|
|
||||||
export const db = drizzle({
|
export const db = drizzle({
|
||||||
@@ -64,12 +69,12 @@ export async function makeMigration() {
|
|||||||
|
|
||||||
const database = drizzle({client: pool});
|
const database = drizzle({client: pool});
|
||||||
|
|
||||||
console.log("Running migrations...");
|
log.info("Running migrations...");
|
||||||
try {
|
try {
|
||||||
await migrate(database, {migrationsFolder: "./src/db/migrations"});
|
await migrate(database, {migrationsFolder: "./src/db/migrations"});
|
||||||
console.log("Migrations applied successfully.");
|
log.info("Migrations applied successfully.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error applying migrations:", error);
|
log.error({error: error}, "Error applying migrations:");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "public"."dbms_status" ADD VALUE 'redis';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "public"."dbms_status" ADD VALUE 'valkey';
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TYPE "public"."healthcheck_status" AS ENUM('success', 'failed');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."healthcheck_kind" AS ENUM('database', 'agent');--> statement-breakpoint
|
||||||
|
CREATE TABLE "healthcheck_log" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"kind" "healthcheck_kind" NOT NULL,
|
||||||
|
"date" timestamp,
|
||||||
|
"status" "healthcheck_status",
|
||||||
|
"object_id" uuid,
|
||||||
|
"updated_at" timestamp,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"deleted_at" timestamp
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "healthcheck_log" ALTER COLUMN "date" SET NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "healthcheck_log" ALTER COLUMN "status" SET NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "healthcheck_log" ALTER COLUMN "object_id" SET NOT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "settings" ADD COLUMN "default_notification_channel_id" uuid;--> statement-breakpoint
|
||||||
|
ALTER TABLE "settings" ADD CONSTRAINT "settings_default_notification_channel_id_notification_channel_id_fk" FOREIGN KEY ("default_notification_channel_id") REFERENCES "public"."notification_channel"("id") ON DELETE set null ON UPDATE no action;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "agents" ADD COLUMN "health_error_count" integer;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "public"."event_kind" ADD VALUE 'error_health_agent';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "public"."event_kind" ADD VALUE 'error_health_database';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "databases" ADD COLUMN "health_error_count" integer;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "public"."dbms_status" ADD VALUE 'mariadb' BEFORE 'mongodb';
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -267,6 +267,76 @@
|
|||||||
"when": 1772200831628,
|
"when": 1772200831628,
|
||||||
"tag": "0037_neat_talon",
|
"tag": "0037_neat_talon",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 38,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773433992397,
|
||||||
|
"tag": "0038_misty_red_hulk",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 39,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773659096498,
|
||||||
|
"tag": "0039_conscious_solo",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 40,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774375027038,
|
||||||
|
"tag": "0040_quick_lester",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 41,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774378412843,
|
||||||
|
"tag": "0041_spooky_radioactive_man",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 42,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774472168308,
|
||||||
|
"tag": "0042_breezy_namora",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 43,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774643615673,
|
||||||
|
"tag": "0043_peaceful_chat",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 44,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774648872657,
|
||||||
|
"tag": "0044_steep_wiccan",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 45,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774696680244,
|
||||||
|
"tag": "0045_needy_martin_li",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 46,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774706071024,
|
||||||
|
"tag": "0046_mysterious_menace",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 47,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774768062658,
|
||||||
|
"tag": "0047_wet_carnage",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import {boolean, pgTable, timestamp, uuid, varchar} from "drizzle-orm/pg-core";
|
import {boolean, pgTable, uuid, varchar} from "drizzle-orm/pg-core";
|
||||||
import {createSelectSchema} from "drizzle-zod";
|
import {createSelectSchema} from "drizzle-zod";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
import {timestamps} from "@/db/schema/00_common";
|
import {timestamps} from "@/db/schema/00_common";
|
||||||
import {storageChannel} from "@/db/schema/12_storage-channel";
|
import {storageChannel} from "@/db/schema/12_storage-channel";
|
||||||
import {relations} from "drizzle-orm";
|
import {relations} from "drizzle-orm";
|
||||||
|
import {notificationChannel} from "@/db/schema/09_notification-channel";
|
||||||
|
|
||||||
export const setting = pgTable("settings", {
|
export const setting = pgTable("settings", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
@@ -15,6 +15,8 @@ export const setting = pgTable("settings", {
|
|||||||
smtpPort: varchar("smtp_port", {length: 255}),
|
smtpPort: varchar("smtp_port", {length: 255}),
|
||||||
smtpUser: varchar("smtp_user", {length: 255}),
|
smtpUser: varchar("smtp_user", {length: 255}),
|
||||||
smtpSecure: boolean("smtp_secure"),
|
smtpSecure: boolean("smtp_secure"),
|
||||||
|
defaultNotificationChannelId: uuid('default_notification_channel_id')
|
||||||
|
.references(() => notificationChannel.id, {onDelete: "set null"}),
|
||||||
defaultStorageChannelId: uuid('default_storage_channel_id')
|
defaultStorageChannelId: uuid('default_storage_channel_id')
|
||||||
.references(() => storageChannel.id, {onDelete: "set null"}),
|
.references(() => storageChannel.id, {onDelete: "set null"}),
|
||||||
encryption: boolean("encryption").default(false),
|
encryption: boolean("encryption").default(false),
|
||||||
@@ -23,6 +25,7 @@ export const setting = pgTable("settings", {
|
|||||||
|
|
||||||
export const settingRelations = relations(setting, ({one, many}) => ({
|
export const settingRelations = relations(setting, ({one, many}) => ({
|
||||||
storageChannel: one(storageChannel, {fields: [setting.defaultStorageChannelId], references: [storageChannel.id]}),
|
storageChannel: one(storageChannel, {fields: [setting.defaultStorageChannelId], references: [storageChannel.id]}),
|
||||||
|
notificationChannel: one(notificationChannel, {fields: [setting.defaultNotificationChannelId], references: [notificationChannel.id]}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const database = pgTable("databases", {
|
|||||||
backupPolicy: text("backup_policy"),
|
backupPolicy: text("backup_policy"),
|
||||||
isWaitingForBackup: boolean("is_waiting_for_backup").default(false).notNull(),
|
isWaitingForBackup: boolean("is_waiting_for_backup").default(false).notNull(),
|
||||||
backupToRestore: text("backup_to_restore"),
|
backupToRestore: text("backup_to_restore"),
|
||||||
|
healthErrorCount: integer("health_error_count"),
|
||||||
agentId: uuid("agent_id")
|
agentId: uuid("agent_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => agent.id, {onDelete: "cascade"}),
|
.references(() => agent.id, {onDelete: "cascade"}),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {boolean, pgTable, text, timestamp, uuid} from "drizzle-orm/pg-core";
|
import {boolean, pgTable, text, timestamp, uuid, integer} from "drizzle-orm/pg-core";
|
||||||
import {createSelectSchema} from "drizzle-zod";
|
import {createSelectSchema} from "drizzle-zod";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
import {Database, database} from "@/db/schema/07_database";
|
import {Database, database} from "@/db/schema/07_database";
|
||||||
@@ -10,6 +10,7 @@ export const agent = pgTable("agents", {
|
|||||||
slug: text("slug").notNull().unique(),
|
slug: text("slug").notNull().unique(),
|
||||||
version: text("version"),
|
version: text("version"),
|
||||||
name: text("name").notNull().notNull(),
|
name: text("name").notNull().notNull(),
|
||||||
|
healthErrorCount: integer("health_error_count"),
|
||||||
description: text("description").notNull(),
|
description: text("description").notNull(),
|
||||||
isArchived: boolean("is_archived").default(false),
|
isArchived: boolean("is_archived").default(false),
|
||||||
lastContact: timestamp("last_contact"),
|
lastContact: timestamp("last_contact"),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {database} from "@/db/schema/07_database";
|
|||||||
import {createSelectSchema} from "drizzle-zod";
|
import {createSelectSchema} from "drizzle-zod";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
|
|
||||||
export const eventKindEnum = pgEnum('event_kind', ['error_backup', 'error_restore', 'success_restore', 'success_backup', 'weekly_report']);
|
export const eventKindEnum = pgEnum('event_kind', ['error_backup', 'error_restore', 'success_restore', 'success_backup', 'weekly_report', 'error_health_agent', 'error_health_database']);
|
||||||
|
|
||||||
export const alertPolicy = pgTable('alert_policy', {
|
export const alertPolicy = pgTable('alert_policy', {
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import {pgTable, uuid, timestamp, pgEnum} from 'drizzle-orm/pg-core';
|
||||||
|
import {timestamps} from "@/db/schema/00_common";
|
||||||
|
import {createSelectSchema} from "drizzle-zod";
|
||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const healthcheckKindEnum = pgEnum('healthcheck_kind', ['database', 'agent']);
|
||||||
|
export const healthCheckStatusEnum = pgEnum('healthcheck_status', ['success', 'failed']);
|
||||||
|
|
||||||
|
|
||||||
|
export const healthcheckLog = pgTable('healthcheck_log', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
kind: healthcheckKindEnum('kind').notNull(),
|
||||||
|
date: timestamp("date").notNull(),
|
||||||
|
status: healthCheckStatusEnum("status").notNull(),
|
||||||
|
objectId: uuid('object_id').notNull(),
|
||||||
|
...timestamps
|
||||||
|
});
|
||||||
|
|
||||||
|
export const healthcheckLogSchema = createSelectSchema(healthcheckLog);
|
||||||
|
export type HealthcheckLog = z.infer<typeof healthcheckLogSchema>;
|
||||||
|
|
||||||
|
export type HealthcheckKind = (typeof healthcheckKindEnum.enumValues)[number];
|
||||||
|
export type HealthcheckStatus = (typeof healthCheckStatusEnum.enumValues)[number];
|
||||||
@@ -2,7 +2,7 @@ import {pgEnum} from "drizzle-orm/pg-core";
|
|||||||
import {createSelectSchema} from "drizzle-zod";
|
import {createSelectSchema} from "drizzle-zod";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
|
|
||||||
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql", "mongodb", "sqlite"]);
|
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql", "mariadb", "mongodb", "sqlite", "redis", "valkey"]);
|
||||||
export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
|
export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
|
||||||
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
|
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import {db} from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {and, eq, gte, isNotNull, lt} from "drizzle-orm";
|
||||||
|
import {dispatchNotification} from "@/features/notifications/dispatch";
|
||||||
|
import {EventPayload} from "@/features/notifications/types";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
|
||||||
|
const log = logger.child({module: "tasks/healthcheck"});
|
||||||
|
|
||||||
|
export async function getHealthLast12hLogs({id}: { id: string }) {
|
||||||
|
const now = new Date()
|
||||||
|
const since = new Date(now.getTime() - 12 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(drizzleDb.schemas.healthcheckLog)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(drizzleDb.schemas.healthcheckLog.objectId, id),
|
||||||
|
gte(drizzleDb.schemas.healthcheckLog.date, since)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHealthLogsOlderThan12h() {
|
||||||
|
const now = new Date()
|
||||||
|
const threshold = new Date(now.getTime() - 12 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
const logsToDelete = await db
|
||||||
|
.select()
|
||||||
|
.from(drizzleDb.schemas.healthcheckLog)
|
||||||
|
.where(
|
||||||
|
lt(drizzleDb.schemas.healthcheckLog.date, threshold)
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info({name: "deleteHealthLogsOlderThan12h"},`Number of logs found to delete: ${logsToDelete.length}`)
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(drizzleDb.schemas.healthcheckLog)
|
||||||
|
.where(
|
||||||
|
lt(drizzleDb.schemas.healthcheckLog.date, threshold)
|
||||||
|
)
|
||||||
|
|
||||||
|
return logsToDelete.length
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkAgentsHealthError() {
|
||||||
|
const agents = await db.query.agent.findMany({
|
||||||
|
where: isNotNull(drizzleDb.schemas.agent.lastContact),
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = await db.query.setting.findFirst({
|
||||||
|
where: (fields, {eq}) => eq(fields.name, "system"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
throw new Error("System settings not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings.defaultNotificationChannelId) {
|
||||||
|
log.error({name: "checkAgentsHealthError"},`No default notification channel id found.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (const agent of agents) {
|
||||||
|
if (!agent.lastContact) continue;
|
||||||
|
|
||||||
|
const lastContactDate = new Date(agent.lastContact);
|
||||||
|
const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60;
|
||||||
|
|
||||||
|
if (diffMinutes > 10) {
|
||||||
|
if ((agent.healthErrorCount ?? 0) < 3) {
|
||||||
|
|
||||||
|
const newHealthErrorCount = (agent.healthErrorCount ?? 0) + 1
|
||||||
|
await db.update(drizzleDb.schemas.agent)
|
||||||
|
.set({
|
||||||
|
healthErrorCount: newHealthErrorCount,
|
||||||
|
})
|
||||||
|
.where(eq(drizzleDb.schemas.agent.id, agent.id));
|
||||||
|
|
||||||
|
const payload: EventPayload = {
|
||||||
|
title: "Agent down",
|
||||||
|
message: `Agent ${agent.name} is down, (notification number: ${newHealthErrorCount}/3)`,
|
||||||
|
level: "critical",
|
||||||
|
event: "error_health_agent",
|
||||||
|
data: {
|
||||||
|
agent: agent.name,
|
||||||
|
id: agent.id,
|
||||||
|
error: "Agent is down",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
log.info({name: "checkAgentsHealthError", payload: payload},`Agent Healthcheck Notification`)
|
||||||
|
|
||||||
|
await dispatchNotification(
|
||||||
|
payload,
|
||||||
|
undefined,
|
||||||
|
settings.defaultNotificationChannelId,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export async function checkDatabasesHealthError() {
|
||||||
|
|
||||||
|
const databases = await db.query.database.findMany({
|
||||||
|
where: isNotNull(drizzleDb.schemas.database.lastContact),
|
||||||
|
with: {
|
||||||
|
agent: true,
|
||||||
|
alertPolicies: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (const database of databases) {
|
||||||
|
if (!database.lastContact) continue;
|
||||||
|
|
||||||
|
const lastContactDate = new Date(database.lastContact);
|
||||||
|
const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60;
|
||||||
|
|
||||||
|
if (diffMinutes > 10) {
|
||||||
|
if ((database.healthErrorCount ?? 0) < 3) {
|
||||||
|
|
||||||
|
const newHealthErrorCount = (database.healthErrorCount ?? 0) + 1
|
||||||
|
await db.update(drizzleDb.schemas.database)
|
||||||
|
.set({
|
||||||
|
healthErrorCount: newHealthErrorCount,
|
||||||
|
})
|
||||||
|
.where(eq(drizzleDb.schemas.database.id, database.id));
|
||||||
|
|
||||||
|
const settings = await db.query.setting.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.setting.name, "system"),
|
||||||
|
with: { notificationChannel: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultPolicy = settings?.notificationChannel
|
||||||
|
? [{
|
||||||
|
id: null,
|
||||||
|
notificationChannelId: settings.notificationChannel.id,
|
||||||
|
enabled: settings.notificationChannel.enabled,
|
||||||
|
eventKinds: ["error_health_database"]
|
||||||
|
}]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const policiesToUse = (database.alertPolicies && database.alertPolicies.length > 0)
|
||||||
|
? database.alertPolicies.filter(policy => policy.enabled && policy.eventKinds.includes("error_health_database"))
|
||||||
|
: defaultPolicy;
|
||||||
|
|
||||||
|
if (!policiesToUse || policiesToUse.length === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const promises = policiesToUse.map(alertPolicy => {
|
||||||
|
|
||||||
|
const payload: EventPayload = {
|
||||||
|
title: "Database down",
|
||||||
|
message: `Database ${database.name} is down, (notification number: ${newHealthErrorCount}/3)`,
|
||||||
|
level: "critical",
|
||||||
|
event: "error_health_database",
|
||||||
|
data: {
|
||||||
|
agent: database.name,
|
||||||
|
id: database.id,
|
||||||
|
error: "Database is down",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
log.info({name: "checkDatabasesHealthError", payload: payload},`Database Healthcheck Notification`)
|
||||||
|
|
||||||
|
return dispatchNotification(payload, alertPolicy.id == null ? undefined : alertPolicy.id, alertPolicy.id ? undefined : alertPolicy.notificationChannelId, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
+15
@@ -39,6 +39,20 @@ export const env = createEnv({
|
|||||||
process.env.NODE_ENV === "production" ? "0 7 * * *" : "* * * * *",
|
process.env.NODE_ENV === "production" ? "0 7 * * *" : "* * * * *",
|
||||||
),
|
),
|
||||||
|
|
||||||
|
CLEANING_HEALTHCHECK_LOGS_CRON: z
|
||||||
|
.string()
|
||||||
|
.default(
|
||||||
|
process.env.NODE_ENV === "production" ? "0 * * * *" : "* * * * *",
|
||||||
|
),
|
||||||
|
|
||||||
|
HEALTHCHECK_CRON: z
|
||||||
|
.string()
|
||||||
|
.default(
|
||||||
|
process.env.NODE_ENV === "production" ? "0 * * * *" : "* * * * *",
|
||||||
|
),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
AUTH_OIDC_ID: z.string().optional().default("oidc"),
|
AUTH_OIDC_ID: z.string().optional().default("oidc"),
|
||||||
AUTH_OIDC_TITLE: z.string().optional(),
|
AUTH_OIDC_TITLE: z.string().optional(),
|
||||||
AUTH_OIDC_DESC: z.string().optional(),
|
AUTH_OIDC_DESC: z.string().optional(),
|
||||||
@@ -95,6 +109,7 @@ export const env = createEnv({
|
|||||||
SMTP_SECURE: process.env.SMTP_SECURE,
|
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||||
|
|
||||||
RETENTION_CRON: process.env.RETENTION_CRON,
|
RETENTION_CRON: process.env.RETENTION_CRON,
|
||||||
|
CLEANING_HEALTHCHECK_LOGS_CRON: process.env.CLEANING_HEALTHCHECK_LOGS_CRON,
|
||||||
|
|
||||||
AUTH_OIDC_ID: process.env.AUTH_OIDC_ID,
|
AUTH_OIDC_ID: process.env.AUTH_OIDC_ID,
|
||||||
AUTH_OIDC_TITLE: process.env.AUTH_OIDC_TITLE,
|
AUTH_OIDC_TITLE: process.env.AUTH_OIDC_TITLE,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {eq, and, ne, count} from "drizzle-orm";
|
|||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {slugify} from "@/utils/slugify";
|
import {slugify} from "@/utils/slugify";
|
||||||
|
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
|
||||||
|
|
||||||
const verifySlugUniqueness = async (slug: string, agentId?: string) => {
|
const verifySlugUniqueness = async (slug: string, agentId?: string) => {
|
||||||
const conditions = agentId ? and(eq(drizzleDb.schemas.agent.slug, slug), ne(drizzleDb.schemas.agent.id, agentId)) : eq(drizzleDb.schemas.agent.slug, slug);
|
const conditions = agentId ? and(eq(drizzleDb.schemas.agent.slug, slug), ne(drizzleDb.schemas.agent.id, agentId)) : eq(drizzleDb.schemas.agent.slug, slug);
|
||||||
@@ -54,7 +55,10 @@ export const getAgentAction = userAction.schema(z.string()).action(async ({parse
|
|||||||
databases: true
|
databases: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: agent,
|
data: agent,
|
||||||
|
health: agent ? await getHealthLast12hLogs({ id: agent.id }) : []
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {formatLocalizedDate} from "@/utils/date-formatting";
|
|||||||
import {formatBytes} from "@/utils/text";
|
import {formatBytes} from "@/utils/text";
|
||||||
import {DatabaseActionsCell} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions-cell";
|
import {DatabaseActionsCell} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions-cell";
|
||||||
import { Badge as BadgeC } from "@/components/ui/badge";
|
import { Badge as BadgeC } from "@/components/ui/badge";
|
||||||
|
import {backupOnly} from "@/components/wrappers/dashboard/projects/database/database-tabs";
|
||||||
|
|
||||||
export function backupColumns(
|
export function backupColumns(
|
||||||
isAlreadyRestore: boolean,
|
isAlreadyRestore: boolean,
|
||||||
@@ -18,6 +19,10 @@ export function backupColumns(
|
|||||||
database: DatabaseWith,
|
database: DatabaseWith,
|
||||||
activeMember: MemberWithUser
|
activeMember: MemberWithUser
|
||||||
): ColumnDef<Backup>[] {
|
): ColumnDef<Backup>[] {
|
||||||
|
|
||||||
|
const isBackupOnly = backupOnly.some((type) => database.dbms === type)
|
||||||
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "availability",
|
id: "availability",
|
||||||
@@ -90,137 +95,7 @@ export function backupColumns(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({row}) => <DatabaseActionsCell isAlreadyRestore={isAlreadyRestore} activeMember={activeMember} backup={row.original}/>,
|
cell: ({row}) => <DatabaseActionsCell isAlreadyRestore={isAlreadyRestore} activeMember={activeMember} backup={row.original} isBackupOnly={isBackupOnly}/>,
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// id: "actions",
|
|
||||||
// cell: ({row, table}) => {
|
|
||||||
// const status = row.getValue("status");
|
|
||||||
// const rowData: Backup = row.original;
|
|
||||||
// const fileName = rowData.file;
|
|
||||||
//
|
|
||||||
// const router = useRouter();
|
|
||||||
//
|
|
||||||
// const mutationRestore = useMutation({
|
|
||||||
// mutationFn: async () => {
|
|
||||||
// const restoration = await createRestorationAction({
|
|
||||||
// backupId: rowData.id,
|
|
||||||
// databaseId: rowData.databaseId,
|
|
||||||
// });
|
|
||||||
// // @ts-ignore
|
|
||||||
// if (restoration.data.success) {
|
|
||||||
// // @ts-ignore
|
|
||||||
// toast.success(restoration.data.actionSuccess?.message || "Restoration created successfully!");
|
|
||||||
// router.refresh();
|
|
||||||
// } else {
|
|
||||||
// // @ts-ignore
|
|
||||||
// toast.error(restoration.serverError || "Failed to create restoration.");
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// const mutationDeleteBackup = useMutation({
|
|
||||||
// mutationFn: async () => {
|
|
||||||
// const deletion = await deleteBackupAction({
|
|
||||||
// backupId: rowData.id,
|
|
||||||
// databaseId: rowData.databaseId,
|
|
||||||
// status: rowData.status,
|
|
||||||
// file: rowData.file ?? "",
|
|
||||||
// projectSlug: database.project?.slug!
|
|
||||||
// });
|
|
||||||
// // @ts-ignore
|
|
||||||
// if (deletion.data.success) {
|
|
||||||
// // @ts-ignore
|
|
||||||
// toast.success(deletion.data.actionSuccess.message);
|
|
||||||
// router.refresh();
|
|
||||||
// } else {
|
|
||||||
// // @ts-ignore
|
|
||||||
// toast.error(deletion.data.actionError.message);
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// const handleRestore = async () => {
|
|
||||||
// await mutationRestore.mutateAsync();
|
|
||||||
// };
|
|
||||||
//
|
|
||||||
// const handleDelete = async () => {
|
|
||||||
// await mutationDeleteBackup.mutateAsync();
|
|
||||||
// };
|
|
||||||
//
|
|
||||||
// const handleDownload = async (fileName: string) => {
|
|
||||||
//
|
|
||||||
// let url: string = "";
|
|
||||||
// let data: SafeActionResult<string, ZodString, readonly [], {
|
|
||||||
// _errors?: string[] | undefined;
|
|
||||||
// }, readonly [], ServerActionResult<string>, object> | undefined
|
|
||||||
//
|
|
||||||
// if (settings.storage == "local") {
|
|
||||||
// data = await getFileUrlPresignedLocal({fileName: fileName!})
|
|
||||||
// } else if (settings.storage == "s3") {
|
|
||||||
// data = await getFileUrlPreSignedS3Action(`backups/${database.project?.slug}/${fileName}`);
|
|
||||||
// }
|
|
||||||
// if (data?.data?.success) {
|
|
||||||
// url = data.data.value ?? "";
|
|
||||||
// } else {
|
|
||||||
// // @ts-ignore
|
|
||||||
// const errorMessage = data?.data?.actionError?.message || "Failed to get file!";
|
|
||||||
// toast.error(errorMessage);
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// window.open(url, "_self");
|
|
||||||
// };
|
|
||||||
//
|
|
||||||
// return (
|
|
||||||
// <>
|
|
||||||
// {(rowData.deletedAt == null && activeMember.role != "member") && (
|
|
||||||
// <DropdownMenu>
|
|
||||||
// <DropdownMenuTrigger asChild>
|
|
||||||
// <Button variant="ghost" className="h-8 w-8 p-0">
|
|
||||||
// <span className="sr-only">Open menu</span>
|
|
||||||
// <MoreHorizontal className="h-4 w-4"/>
|
|
||||||
// </Button>
|
|
||||||
// </DropdownMenuTrigger>
|
|
||||||
// <DropdownMenuContent align="end">
|
|
||||||
// <DropdownMenuLabel>Actions</DropdownMenuLabel>
|
|
||||||
// {status == "success" ? (
|
|
||||||
// <>
|
|
||||||
// <TooltipCustom disabled={isAlreadyRestore}
|
|
||||||
// text="Already a restoration waiting">
|
|
||||||
// <DropdownMenuItem
|
|
||||||
// disabled={mutationRestore.isPending || isAlreadyRestore}
|
|
||||||
// onClick={async () => {
|
|
||||||
// await handleRestore();
|
|
||||||
// }}
|
|
||||||
// >
|
|
||||||
// <ReloadIcon/> Restore
|
|
||||||
// </DropdownMenuItem>
|
|
||||||
// </TooltipCustom>
|
|
||||||
// <DropdownMenuItem
|
|
||||||
// onClick={async () => {
|
|
||||||
// await handleDownload(fileName ?? "");
|
|
||||||
// }}
|
|
||||||
// >
|
|
||||||
// <Download/> Download
|
|
||||||
// </DropdownMenuItem>
|
|
||||||
// </>
|
|
||||||
// ) : null}
|
|
||||||
// <DropdownMenuSeparator/>
|
|
||||||
// <DropdownMenuItem
|
|
||||||
// className="text-red-600"
|
|
||||||
// onClick={async () => {
|
|
||||||
// await handleDelete();
|
|
||||||
// }}
|
|
||||||
// >
|
|
||||||
// <Trash2/> Delete
|
|
||||||
// </DropdownMenuItem>
|
|
||||||
// </DropdownMenuContent>
|
|
||||||
// </DropdownMenu>
|
|
||||||
// )}
|
|
||||||
// </>
|
|
||||||
//
|
|
||||||
// );
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user