mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
69
Commits
1.2.5-rc.6
...
1.2.7-rc.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a4af58a51 | ||
|
|
b12f8ca044 | ||
|
|
519b3abd1d | ||
|
|
e08a5bd6b6 | ||
|
|
f6c597b42d | ||
|
|
f3b9032174 | ||
|
|
44b4ee2953 | ||
|
|
b77f79f42d | ||
|
|
85f4cda752 | ||
|
|
1fd2f553ed | ||
|
|
d68170eda8 | ||
|
|
51f27226e7 | ||
|
|
d54c366323 | ||
|
|
0e7cb9fbab | ||
|
|
63e83032f1 | ||
|
|
b4da6fdd9f | ||
|
|
12535a59e8 | ||
|
|
b345eda7aa | ||
|
|
660c1dcadf | ||
|
|
be66856034 | ||
|
|
80e3027107 | ||
|
|
362ca53efb | ||
|
|
7ab213e5e4 | ||
|
|
f0eafb1162 | ||
|
|
ad5baa7d6f | ||
|
|
5840e30afb | ||
|
|
8f95606dd7 | ||
|
|
ca9bc7ae94 | ||
|
|
288a40e697 | ||
|
|
fa91e8c289 | ||
|
|
4ab5fe4c6f | ||
|
|
d87782d180 | ||
|
|
1c97c97fa1 | ||
|
|
2426166676 | ||
|
|
6f6c1c7c52 | ||
|
|
7b9cb2ecb9 | ||
|
|
fc029af654 | ||
|
|
110a69dc4b | ||
|
|
a61791fdb2 | ||
|
|
c01440f9d4 | ||
|
|
39584dd7a1 | ||
|
|
d9badeacba | ||
|
|
d4cde2ba9c | ||
|
|
824ef4528e | ||
|
|
feb06a85ae | ||
|
|
cfad645e33 | ||
|
|
675f8dec20 | ||
|
|
11e85b613c | ||
|
|
eb82833c7e | ||
|
|
399a3e09de | ||
|
|
1a735bda42 | ||
|
|
991196cc30 | ||
|
|
5f48903a73 | ||
|
|
5dfe1b758e | ||
|
|
d24e534828 | ||
|
|
80bdca138e | ||
|
|
0a2ca8c0b6 | ||
|
|
921a355ae0 | ||
|
|
eff7b24b7a | ||
|
|
c328620f3d | ||
|
|
a49b6d6573 | ||
|
|
baffd2efaf | ||
|
|
292693a1ed | ||
|
|
cdd01e695c | ||
|
|
614a9e057c | ||
|
|
24d90bb750 | ||
|
|
db1a0235ed | ||
|
|
2cd6a7cae0 | ||
|
|
bd80fdc960 |
@@ -4,10 +4,6 @@ on:
|
|||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
image_name:
|
image_name:
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
default: 'solucetechnologies/portabase'
|
|
||||||
image_name2:
|
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
default: 'portabase/portabase'
|
default: 'portabase/portabase'
|
||||||
@@ -28,70 +24,99 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
DOCKER_PASSWORD:
|
DOCKER_PASSWORD:
|
||||||
required: true
|
required: true
|
||||||
DOCKER_USERNAME_2:
|
|
||||||
required: true
|
|
||||||
DOCKER_PASSWORD_2:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
|
||||||
runs-on: ubuntu-latest
|
build:
|
||||||
permissions:
|
name: Build and push Docker images
|
||||||
packages: write
|
runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' }}
|
||||||
contents: read
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
platform: [ linux/amd64, linux/arm64 ]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set tags
|
- name: Login to Docker
|
||||||
id: set-tags
|
|
||||||
run: |
|
|
||||||
REF_NAME=${GITHUB_REF#refs/tags/}
|
|
||||||
TAGS="${{ inputs.image_name }}:$REF_NAME"
|
|
||||||
TAGS2="${{ inputs.image_name2 }}:$REF_NAME"
|
|
||||||
if [[ "${{ inputs.add_latest }}" == "true" ]]; then
|
|
||||||
TAGS="$TAGS,${{ inputs.image_name }}:latest"
|
|
||||||
TAGS2="$TAGS2,${{ inputs.image_name2 }}:latest"
|
|
||||||
fi
|
|
||||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
|
||||||
echo "tags2=$TAGS2" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Set up QEMU (enables multi-arch emulation)
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub account 1
|
|
||||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Build and push to solucetechnologies/portabase
|
- name: Set image tag
|
||||||
|
id: set-tags
|
||||||
|
run: |
|
||||||
|
REF_NAME=${GITHUB_REF#refs/tags/}
|
||||||
|
if [ "${{ matrix.platform }}" = "linux/amd64" ]; then
|
||||||
|
IMAGE="${{ inputs.image_name }}:$REF_NAME-amd64"
|
||||||
|
else
|
||||||
|
IMAGE="${{ inputs.image_name }}:$REF_NAME-arm64"
|
||||||
|
fi
|
||||||
|
echo "image=$IMAGE" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ${{ inputs.dockerfile }}
|
file: ${{ inputs.dockerfile }}
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: ${{ matrix.platform }}
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.set-tags.outputs.tags }}
|
tags: ${{ steps.set-tags.outputs.image }}
|
||||||
target: ${{ inputs.target }}
|
target: ${{ inputs.target }}
|
||||||
|
|
||||||
- name: Log in to Docker Hub account 2
|
- name: Prepare artifact name
|
||||||
|
id: artifact
|
||||||
|
run: |
|
||||||
|
platform=${{ matrix.platform }}
|
||||||
|
echo "safe_platform=${platform//\//-}" >> $GITHUB_OUTPUT
|
||||||
|
echo "${{ steps.set-tags.outputs.image }}" > image.txt
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||||
|
with:
|
||||||
|
name: image-${{ steps.artifact.outputs.safe_platform }}
|
||||||
|
path: image.txt
|
||||||
|
if-no-files-found: warn
|
||||||
|
compression-level: 6
|
||||||
|
overwrite: false
|
||||||
|
include-hidden-files: false
|
||||||
|
|
||||||
|
create-manifest:
|
||||||
|
name: Create multi-arch Docker manifest (Portabase)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
|
||||||
|
with:
|
||||||
|
name: image-linux-amd64
|
||||||
|
path: /tmp/digests/amd64
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
|
||||||
|
with:
|
||||||
|
name: image-linux-arm64
|
||||||
|
path: /tmp/digests/arm64
|
||||||
|
|
||||||
|
- name: Login to Docker
|
||||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME_2 }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD_2 }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Create and push manifest list
|
||||||
|
working-directory: /tmp/digests
|
||||||
|
run: |
|
||||||
|
DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)"
|
||||||
|
REF_NAME=${GITHUB_REF#refs/tags/}
|
||||||
|
MANIFEST_IMAGE="${{ inputs.image_name }}:$REF_NAME"
|
||||||
|
|
||||||
|
docker buildx imagetools create $DOCKER_IMAGES -t $MANIFEST_IMAGE
|
||||||
|
docker buildx imagetools inspect $MANIFEST_IMAGE
|
||||||
|
|
||||||
|
if [ "${{ inputs.add_latest }}" = "true" ]; then
|
||||||
|
docker buildx imagetools create $DOCKER_IMAGES -t ${{ inputs.image_name }}:latest
|
||||||
|
docker buildx imagetools inspect ${{ inputs.image_name }}:latest
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- name: Build and push to portabase/portabase
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ${{ inputs.dockerfile }}
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.set-tags.outputs.tags2 }}
|
|
||||||
target: ${{ inputs.target }}
|
|
||||||
|
|||||||
@@ -15,10 +15,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
add_latest: false
|
add_latest: false
|
||||||
secrets:
|
secrets:
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||||
DOCKER_USERNAME_2: ${{ secrets.DOCKER_USERNAME_2 }}
|
|
||||||
DOCKER_PASSWORD_2: ${{ secrets.DOCKER_PASSWORD_2 }}
|
|
||||||
|
|
||||||
github_release:
|
github_release:
|
||||||
needs: docker_publish
|
needs: docker_publish
|
||||||
|
|||||||
@@ -16,10 +16,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
add_latest: true
|
add_latest: true
|
||||||
secrets:
|
secrets:
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||||
DOCKER_USERNAME_2: ${{ secrets.DOCKER_USERNAME_2 }}
|
|
||||||
DOCKER_PASSWORD_2: ${{ secrets.DOCKER_PASSWORD_2 }}
|
|
||||||
|
|
||||||
github_release:
|
github_release:
|
||||||
needs: docker_publish
|
needs: docker_publish
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ public/uploads/*
|
|||||||
!public/uploads/
|
!public/uploads/
|
||||||
|
|
||||||
private/uploads/*
|
private/uploads/*
|
||||||
|
private/keys/*
|
||||||
|
|
||||||
/.env
|
/.env
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -2,9 +2,7 @@ title = "Custom gitleaks config"
|
|||||||
|
|
||||||
[allowlist]
|
[allowlist]
|
||||||
# Global allowlist patterns (won’t be flagged)
|
# Global allowlist patterns (won’t be flagged)
|
||||||
regexes = [
|
regexes = []
|
||||||
"s3SecretAccessKey: env.S3_SECRET_KEY ?? null",
|
|
||||||
]
|
|
||||||
paths = [
|
paths = [
|
||||||
"src/utils/init.ts"
|
"src/utils/init.ts"
|
||||||
]
|
]
|
||||||
|
|||||||
+2
-2
@@ -26,5 +26,5 @@ keywords:
|
|||||||
- web-ui
|
- web-ui
|
||||||
- agent
|
- agent
|
||||||
license: Apache-2.0
|
license: Apache-2.0
|
||||||
version: 1.2.5-rc.6
|
version: 1.2.7-rc.2
|
||||||
date-released: "2026-02-01"
|
date-released: "2026-02-16"
|
||||||
|
|||||||
@@ -186,7 +186,7 @@
|
|||||||
same "printed page" as the copyright notice for easier
|
same "printed page" as the copyright notice for easier
|
||||||
identification within third-party archives.
|
identification within third-party archives.
|
||||||
|
|
||||||
Copyright 2024 Soluce Technologies
|
Copyright 2024 Portabase
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<h3 align="center">Portabase</h3>
|
<h3 align="center">Portabase</h3>
|
||||||
|
|
||||||
<p align="center" style="margin-top: 20px; font-style: italic;">
|
<p align="center" style="margin-top: 20px; font-style: italic;">
|
||||||
<i>Portabase is a tool designed to simplify the backup and restoration of your database instances. It integrates seamlessly with <a href="https://github.com/Portabase/agent">Portabase agents</a> for managing operations securely and efficiently.</i>
|
<i>Portabase is a tool designed to simplify the backup and restoration of your database instances. It integrates seamlessly with <a href="https://github.com/Portabase/agent-rust">Portabase agents</a> for managing operations securely and efficiently.</i>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
@@ -23,7 +23,6 @@
|
|||||||
[](https://github.com/Portabase/portabase)
|
[](https://github.com/Portabase/portabase)
|
||||||
[](https://github.com/Portabase/portabase)
|
[](https://github.com/Portabase/portabase)
|
||||||
|
|
||||||
|
|
||||||
[![NextJS][NextJS]][NextJS-url]
|
[![NextJS][NextJS]][NextJS-url]
|
||||||
[![BetterAuth][BetterAuth]][BetterAuth-url]
|
[![BetterAuth][BetterAuth]][BetterAuth-url]
|
||||||
[![Drizzle][Drizzle]][Drizzle-url]
|
[![Drizzle][Drizzle]][Drizzle-url]
|
||||||
@@ -51,14 +50,13 @@
|
|||||||
You have 4 ways to install Portabase:
|
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 Compose setup - [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)
|
||||||
- Kubernetes with Helm (soon)
|
- Kubernetes with Helm (soon)
|
||||||
- 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.**
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
[](https://github.com/Portabase/portabase/graphs/contributors)
|
[](https://github.com/Portabase/portabase/graphs/contributors)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default async function GuardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<CardAuth className="w-full max-w-md">
|
<CardAuth className="w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="grid gap-2 text-center mb-2">
|
<div className="grid gap-2 text-center mb-2">
|
||||||
<h1 className="text-3xl font-bold">Two-factor verification</h1>
|
<h1 className="text-3xl font-bold">Two-factor verification</h1>
|
||||||
|
|||||||
+11
-4
@@ -2,6 +2,8 @@ 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";
|
||||||
|
|
||||||
export default async function Layout({children}: { children: React.ReactNode }) {
|
export default async function Layout({children}: { children: React.ReactNode }) {
|
||||||
|
|
||||||
@@ -12,13 +14,18 @@ export default async function Layout({children}: { children: React.ReactNode })
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8 ">
|
<div className="flex min-h-screen flex-col justify-between py-10 sm:px-6 lg:px-8 ">
|
||||||
|
<div className="flex flex-col items-center justify-center flex-1">
|
||||||
<div className="mx-auto w-full max-w-md">
|
<div className="mx-auto w-full max-w-md">
|
||||||
<AuthLogoSection/>
|
<AuthLogoSection/>
|
||||||
<div>{children}</div>
|
<div className="mt-4">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
<footer className="py-4 text-center text-xs justify-items-end text-muted-foreground">
|
</div>
|
||||||
Powered by <span className="font-medium">Soluce Technologies</span>
|
<footer className="mt-8 text-center text-xs text-muted-foreground flex flex-col gap-1">
|
||||||
|
<p className="flex items-center justify-center gap-1">
|
||||||
|
Made with <Heart className="size-3 fill-red-500 text-red-500" /> by <span className="font-medium text-foreground">Portabase</span>
|
||||||
|
</p>
|
||||||
|
<p>v{env.NEXT_PUBLIC_PROJECT_VERSION}</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { CardContent, CardHeader } from "@/components/ui/card";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-password-form/reset-password-form";
|
import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-password-form/reset-password-form";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth/auth";
|
import { auth } from "@/lib/auth/auth";
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
|
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
|
||||||
|
import {CardAuth} from "@/features/layout/card-auth";
|
||||||
|
|
||||||
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
|
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ export default async function RoutePage(props: { searchParams: Promise<{ token:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Card className="w-full max-w-md shadow-lg">
|
<CardAuth className="w-full">
|
||||||
<CardHeader className="space-y-4">
|
<CardHeader className="space-y-4">
|
||||||
<div className="space-y-1 text-center">
|
<div className="space-y-1 text-center">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Set a new password</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Set a new password</h1>
|
||||||
@@ -53,7 +54,7 @@ export default async function RoutePage(props: { searchParams: Promise<{ token:
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<ResetPasswordForm />
|
<ResetPasswordForm />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</CardAuth>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-61
@@ -1,21 +1,11 @@
|
|||||||
import {PageParams} from "@/types/next";
|
import {PageParams} from "@/types/next";
|
||||||
import {notFound, redirect} from "next/navigation";
|
import {notFound, redirect} from "next/navigation";
|
||||||
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
import {Page} from "@/features/layout/page";
|
||||||
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
|
|
||||||
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
|
|
||||||
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
|
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {eq, and, inArray} from "drizzle-orm";
|
import {eq, and, inArray} from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {getOrganizationProjectDatabases} from "@/lib/services";
|
import {getOrganizationProjectDatabases} from "@/lib/services";
|
||||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||||
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
|
|
||||||
import {capitalizeFirstLetter} from "@/utils/text";
|
|
||||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
|
||||||
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
|
|
||||||
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
|
||||||
import {ChannelPoliciesModal} from "@/components/wrappers/dashboard/database/channels-policy/policy-modal";
|
|
||||||
import {HardDrive, Megaphone} from "lucide-react";
|
|
||||||
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";
|
||||||
|
|
||||||
@@ -93,12 +83,6 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const organizationChannels = await getOrganizationChannels(organization.id);
|
|
||||||
const activeOrganizationChannels = organizationChannels.filter(channel => channel.enabled);
|
|
||||||
|
|
||||||
const organizationStorageChannels = await getOrganizationStorageChannels(organization.id);
|
|
||||||
const activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled);
|
|
||||||
|
|
||||||
|
|
||||||
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
||||||
|
|
||||||
@@ -106,49 +90,6 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
|
|
||||||
<div className="justify-between gap-2 sm:flex">
|
|
||||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
|
||||||
<div className="min-w-full md:min-w-fit ">
|
|
||||||
{capitalizeFirstLetter(dbItem.name)}
|
|
||||||
</div>
|
|
||||||
{!isMember && (
|
|
||||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<RetentionPolicySheet database={dbItem}/>
|
|
||||||
<CronButton database={dbItem}/>
|
|
||||||
<ChannelPoliciesModal
|
|
||||||
database={dbItem}
|
|
||||||
kind={"notification"}
|
|
||||||
icon={<Megaphone/>}
|
|
||||||
channels={activeOrganizationChannels}
|
|
||||||
organizationId={organization.id}
|
|
||||||
/>
|
|
||||||
<ChannelPoliciesModal
|
|
||||||
database={dbItem}
|
|
||||||
icon={<HardDrive/>}
|
|
||||||
kind={"storage"}
|
|
||||||
channels={activeOrganizationStorageChannels}
|
|
||||||
organizationId={organization.id}
|
|
||||||
/>
|
|
||||||
<ImportModal database={dbItem}/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</PageTitle>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dbItem.description && (
|
|
||||||
<PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription>
|
|
||||||
)}
|
|
||||||
<PageContent className="flex flex-col w-full h-full">
|
|
||||||
<DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
|
|
||||||
totalBackups={totalBackups}/>
|
|
||||||
<BackupModalProvider>
|
<BackupModalProvider>
|
||||||
<DatabaseContent
|
<DatabaseContent
|
||||||
activeMember={activeMember}
|
activeMember={activeMember}
|
||||||
@@ -157,9 +98,14 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
isAlreadyRestore={isAlreadyRestore}
|
isAlreadyRestore={isAlreadyRestore}
|
||||||
restorations={restorations}
|
restorations={restorations}
|
||||||
backups={backups}
|
backups={backups}
|
||||||
|
totalBackups={totalBackups}
|
||||||
|
availableBackups={availableBackups}
|
||||||
|
successRate={successRate}
|
||||||
|
organizationId={organization.id}
|
||||||
|
activeOrganizationChannels={[]}
|
||||||
|
activeOrganizationStorageChannels={[]}
|
||||||
/>
|
/>
|
||||||
</BackupModalProvider>
|
</BackupModalProvider>
|
||||||
</PageContent>
|
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
organizationSlug={organization.slug}
|
organizationSlug={organization.slug}
|
||||||
data={projects}
|
data={projects}
|
||||||
cardItem={ProjectCard}
|
cardItem={ProjectCard}
|
||||||
cardsPerPage={6}
|
cardsPerPage={9}
|
||||||
numberOfColumns={3}
|
numberOfColumns={3}
|
||||||
/>
|
/>
|
||||||
) : isMember ? (
|
) : isMember ? (
|
||||||
|
|||||||
@@ -1,53 +1,52 @@
|
|||||||
import fs from "node:fs";
|
import { NextResponse } from "next/server";
|
||||||
import forge from "node-forge";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
|
||||||
|
export function withAgentCheck(handler: Function) {
|
||||||
|
return async (request: Request, context: { params: Promise<{ agentId: string }> }) => {
|
||||||
|
try {
|
||||||
|
const agentId = (await context.params).agentId;
|
||||||
|
|
||||||
export async function decryptedDump(file: File, aesKeyHex: string, ivHex: string, fileExtension: string): Promise<File> {
|
const agent = await db.query.agent.findFirst({
|
||||||
const privateKeyPem = fs.readFileSync("private/keys/server_private.pem", "utf8");
|
where: eq(drizzleDb.schemas.agent.id, agentId),
|
||||||
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
|
|
||||||
|
|
||||||
// Decrypt AES key with RSA-OAEP
|
|
||||||
const encryptedAesKey = forge.util.hexToBytes(aesKeyHex);
|
|
||||||
const aesKey = privateKey.decrypt(encryptedAesKey, "RSA-OAEP", {
|
|
||||||
md: forge.md.sha256.create(),
|
|
||||||
mgf1: {md: forge.md.sha256.create()},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Read encrypted file content
|
if (!agent) {
|
||||||
const encryptedBuffer = Buffer.from(await file.arrayBuffer());
|
return NextResponse.json(
|
||||||
const iv = forge.util.hexToBytes(ivHex);
|
{ error: "Agent not found" },
|
||||||
|
{ status: 404 }
|
||||||
// AES decryption
|
|
||||||
const decipher = forge.cipher.createDecipher("AES-CBC", aesKey);
|
|
||||||
decipher.start({iv});
|
|
||||||
decipher.update(forge.util.createBuffer(encryptedBuffer.toString("binary")));
|
|
||||||
const success = decipher.finish();
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
throw new Error("Decryption failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
const decryptedBytes = decipher.output.getBytes();
|
|
||||||
const decryptedBuffer = Buffer.from(decryptedBytes, "binary");
|
|
||||||
|
|
||||||
// Return a File so you can use file.arrayBuffer() later
|
|
||||||
return new File(
|
|
||||||
[decryptedBuffer],
|
|
||||||
file.name.replace(/\.enc$/, fileExtension),
|
|
||||||
{type: "application/octet-stream"}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return handler(request, { ...context, agent });
|
||||||
export function getFileExtension(dbType: string) {
|
} catch (err) {
|
||||||
switch (dbType) {
|
console.error("Error in agent middleware:", err);
|
||||||
case "postgresql":
|
return NextResponse.json(
|
||||||
return ".dump";
|
{ error: "Internal server error" },
|
||||||
case "mysql":
|
{ status: 500 }
|
||||||
return ".sql";
|
);
|
||||||
default:
|
|
||||||
return ".dump";
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function getDatabaseOrThrow(generatedId: string) {
|
||||||
|
const database = await db.query.database.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
|
||||||
|
with: {
|
||||||
|
project: true,
|
||||||
|
alertPolicies: true,
|
||||||
|
storagePolicies: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!database) {
|
||||||
|
throw NextResponse.json(
|
||||||
|
{ error: "Database associated with generatedId not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return database;
|
||||||
|
}
|
||||||
@@ -1,77 +1,47 @@
|
|||||||
import {NextResponse} from "next/server";
|
import {NextResponse} from "next/server";
|
||||||
import {isUuidv4} from "@/utils/verify-uuid";
|
|
||||||
import {v4 as uuidv4} from "uuid";
|
|
||||||
import * as drizzleDb from "@/db";
|
|
||||||
import {db} from "@/db";
|
|
||||||
import {Backup} from "@/db/schema/07_database";
|
|
||||||
import {and, eq} from "drizzle-orm";
|
import {and, eq} from "drizzle-orm";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {db as dbClient, db} from "@/db";
|
||||||
|
import {getDatabaseOrThrow, withAgentCheck} from "./helpers";
|
||||||
|
import {Backup} from "@/db/schema/07_database";
|
||||||
import {withUpdatedAt} from "@/db/utils";
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
import {decryptedDump, getFileExtension} from "./helpers";
|
|
||||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
|
||||||
import {storeBackupFiles} from "@/features/storages/helpers";
|
|
||||||
import {eventEmitter} from "@/features/shared/event";
|
import {eventEmitter} from "@/features/shared/event";
|
||||||
|
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
||||||
|
import {EventKind} from "@/features/notifications/types";
|
||||||
|
|
||||||
export async function POST(
|
export type BodyPost = {
|
||||||
request: Request,
|
method: "manual" | "automatic"
|
||||||
{params}: { params: Promise<{ agentId: string }> }
|
generatedId: string
|
||||||
) {
|
}
|
||||||
|
|
||||||
|
export type BodyPatch = {
|
||||||
|
backupId: string
|
||||||
|
status: "success" | "failed"
|
||||||
|
size: number
|
||||||
|
generatedId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||||
|
params: Promise<{ agentId: string }>,
|
||||||
|
agent: any
|
||||||
|
}) => {
|
||||||
try {
|
try {
|
||||||
const contentType = request.headers.get("Content-Type");
|
const body: BodyPost = await request.json();
|
||||||
|
const method = body.method
|
||||||
if (!contentType || !contentType.includes("multipart/form-data")) {
|
const database = await getDatabaseOrThrow(body.generatedId);
|
||||||
return NextResponse.json(
|
|
||||||
{error: "Unsupported or missing Content-Type"},
|
|
||||||
{status: 400}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
eventEmitter.emit('modification', {update: true});
|
|
||||||
|
|
||||||
const agentId = (await params).agentId;
|
|
||||||
const formData = await request.formData();
|
|
||||||
const aesKeyHex = formData.get("aes_key") as string;
|
|
||||||
const ivHex = formData.get("iv") as string;
|
|
||||||
const generatedId = formData.get("generatedId") as string | null;
|
|
||||||
const method = formData.get("method") as string | null;
|
|
||||||
|
|
||||||
|
|
||||||
if (!generatedId || !isUuidv4(generatedId)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{error: "generatedId is not a valid UUID"},
|
|
||||||
{status: 400}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const agent = await db.query.agent.findFirst({
|
|
||||||
where: eq(drizzleDb.schemas.agent.id, agentId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!agent) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{error: "Agent not found"},
|
|
||||||
{status: 404}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const database = await db.query.database.findFirst({
|
|
||||||
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
|
|
||||||
with: {
|
|
||||||
project: true,
|
|
||||||
alertPolicies: true,
|
|
||||||
storagePolicies: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!database) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{error: "Database associated with generatedId not found"},
|
|
||||||
{status: 404}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let backup: Backup | null | undefined = null;
|
let backup: Backup | null | undefined = null;
|
||||||
|
|
||||||
if (method === "automatic") {
|
if (method === "automatic") {
|
||||||
|
|
||||||
|
const ongoingBackup = await db.query.backup.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.backup.status, 'ongoing'),
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, database.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ongoingBackup) {
|
||||||
[backup] = await db
|
[backup] = await db
|
||||||
.insert(drizzleDb.schemas.backup)
|
.insert(drizzleDb.schemas.backup)
|
||||||
.values({
|
.values({
|
||||||
@@ -79,14 +49,18 @@ export async function POST(
|
|||||||
databaseId: database.id,
|
databaseId: database.id,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
||||||
if (!backup) {
|
if (!backup) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: "Unable to create an automatic backup"},
|
{error: "Unable to create an automatic backup"},
|
||||||
{status: 500}
|
{status: 500}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: "A backup is already ongoing"},
|
||||||
|
{status: 500}
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
backup = await db.query.backup.findFirst({
|
backup = await db.query.backup.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
@@ -95,7 +69,6 @@ export async function POST(
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
if (!backup) {
|
if (!backup) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: "Unable to find the corresponding backup"},
|
{error: "Unable to find the corresponding backup"},
|
||||||
@@ -104,66 +77,77 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = formData.get("status") as string | null;
|
|
||||||
|
|
||||||
if (status === "success") {
|
|
||||||
const file = formData.get("file") as File | null;
|
|
||||||
const extension = formData.get("extension") as string | null;
|
|
||||||
if (!aesKeyHex || !ivHex) {
|
|
||||||
return NextResponse.json({error: "Missing fields"}, {status: 400});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{error: "File is required for successful backup"},
|
|
||||||
{status: 400}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const fileSizeBytes = file.size;
|
|
||||||
// const fileExtension = '.' + (file.name.split('.').pop()?.toLowerCase() || '');
|
|
||||||
const fileExtension = extension ? extension : getFileExtension(database.dbms)
|
|
||||||
const decryptedFile = await decryptedDump(file, aesKeyHex, ivHex, fileExtension);
|
|
||||||
const uuid = uuidv4();
|
|
||||||
const fileName = `${uuid}${fileExtension}`;
|
|
||||||
const buffer = Buffer.from(await decryptedFile.arrayBuffer());
|
|
||||||
|
|
||||||
|
|
||||||
const storageResults = await storeBackupFiles(backup, database, buffer, fileName)
|
|
||||||
eventEmitter.emit('modification', {update: true});
|
|
||||||
|
|
||||||
await sendNotificationsBackupRestore(database, "success_backup");
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
message: "Backup successfully uploaded",
|
|
||||||
},
|
|
||||||
{status: 200}
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(drizzleDb.schemas.backup)
|
|
||||||
.set(withUpdatedAt({status: 'failed'}))
|
|
||||||
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
|
||||||
|
|
||||||
eventEmitter.emit('modification', {update: true});
|
eventEmitter.emit('modification', {update: true});
|
||||||
|
|
||||||
await sendNotificationsBackupRestore(database, "error_backup");
|
|
||||||
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
message: "Backup successfully updated with status failed",
|
message: "Init backup success",
|
||||||
|
backup: backup,
|
||||||
},
|
},
|
||||||
{status: 200}
|
{status: 200}
|
||||||
);
|
);
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in POST handler:", error);
|
console.error("Error in POST for INIT backup:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: "Internal server error"},
|
{error: "Internal server error"},
|
||||||
{status: 500}
|
{status: 500}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||||
|
params: Promise<{ agentId: string }>,
|
||||||
|
agent: any
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
const body: BodyPatch = await request.json();
|
||||||
|
|
||||||
|
const status = body.status
|
||||||
|
const backupId = body.backupId
|
||||||
|
const backupSize = body.size
|
||||||
|
|
||||||
|
const database = await getDatabaseOrThrow(body.generatedId);
|
||||||
|
|
||||||
|
const backup = await db.query.backup.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.backup.id, backupId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!backup) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: "No backup found"},
|
||||||
|
{status: 500}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [backupUpdated] = await dbClient
|
||||||
|
.update(drizzleDb.schemas.backup)
|
||||||
|
.set(withUpdatedAt({
|
||||||
|
status: status,
|
||||||
|
fileSize: backupSize
|
||||||
|
}))
|
||||||
|
.where(eq(drizzleDb.schemas.backup.id, backup.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
|
||||||
|
eventEmitter.emit('modification', {update: true});
|
||||||
|
await sendNotificationsBackupRestore(database, `${status}_backup` as EventKind);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message: "Backup successfully updated",
|
||||||
|
backup: backupUpdated,
|
||||||
|
},
|
||||||
|
{status: 200}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
|
||||||
|
|
||||||
|
console.error("Error in PATCH backup:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: "Internal server error"},
|
||||||
|
{status: 500}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import {NextResponse} from "next/server";
|
||||||
|
import {and, eq} from "drizzle-orm";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
|
||||||
|
import {isUuidv4} from "@/utils/verify-uuid";
|
||||||
|
import {eventEmitter} from "@/features/shared/event";
|
||||||
|
|
||||||
|
export type Body = {
|
||||||
|
generatedId: string
|
||||||
|
storageChannelId: string
|
||||||
|
backupId: string
|
||||||
|
}
|
||||||
|
export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||||
|
params: Promise<{ agentId: string }>,
|
||||||
|
agent: any
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
const body: Body = await request.json();
|
||||||
|
|
||||||
|
console.log("body", body);
|
||||||
|
|
||||||
|
const generatedId = body.generatedId;
|
||||||
|
const storageChannelId = body.storageChannelId;
|
||||||
|
const backupId = body.backupId;
|
||||||
|
|
||||||
|
if (!generatedId || !isUuidv4(generatedId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: "generatedId is not a valid UUID"},
|
||||||
|
{status: 400}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const database = await getDatabaseOrThrow(generatedId);
|
||||||
|
|
||||||
|
const backup = await db.query.backup.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.backup.id, backupId),
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, database.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!backup) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: "Unable to find the corresponding backup"},
|
||||||
|
{status: 404}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [backupStorage] = await db
|
||||||
|
.insert(drizzleDb.schemas.backupStorage)
|
||||||
|
.values({
|
||||||
|
backupId: backup.id,
|
||||||
|
storageChannelId: storageChannelId,
|
||||||
|
status: "pending",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
eventEmitter.emit('modification', {update: true});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message: "Backup storage successfully created",
|
||||||
|
backupStorage: backupStorage
|
||||||
|
},
|
||||||
|
{status: 200}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in POST for INIT backup:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: "Internal server error"},
|
||||||
|
{status: 500}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import {NextResponse} from "next/server";
|
||||||
|
import {and, eq} from "drizzle-orm";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {db as dbClient, db} from "@/db";
|
||||||
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
|
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
|
||||||
|
import {eventEmitter} from "@/features/shared/event";
|
||||||
|
|
||||||
|
export type Body = {
|
||||||
|
generatedId: string
|
||||||
|
status: "success" | "failed"
|
||||||
|
backupStorageId: string
|
||||||
|
path: string
|
||||||
|
size: number
|
||||||
|
backupId: string
|
||||||
|
}
|
||||||
|
export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||||
|
params: Promise<{ agentId: string }>,
|
||||||
|
agent: any
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
const body: Body = await request.json();
|
||||||
|
const generatedId = body.generatedId;
|
||||||
|
const status = body.status;
|
||||||
|
const filePath = body.path;
|
||||||
|
const fileSize = body.size;
|
||||||
|
const backupStorageId = body.backupStorageId;
|
||||||
|
const backupId = body.backupId;
|
||||||
|
|
||||||
|
|
||||||
|
console.log("body", body);
|
||||||
|
|
||||||
|
const database = await getDatabaseOrThrow(generatedId);
|
||||||
|
|
||||||
|
const backup = await db.query.backup.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.backup.id, backupId),
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, database.id),
|
||||||
|
),
|
||||||
|
with: {
|
||||||
|
storages: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!backup) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: "Unable to find the corresponding backup"},
|
||||||
|
{status: 404}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [backupStorage] = await dbClient
|
||||||
|
.update(drizzleDb.schemas.backupStorage)
|
||||||
|
.set(withUpdatedAt({
|
||||||
|
status: status,
|
||||||
|
path: filePath,
|
||||||
|
size: fileSize
|
||||||
|
}))
|
||||||
|
.where(eq(drizzleDb.schemas.backupStorage.id, backupStorageId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
|
||||||
|
if (backup.storages.length > 0) {
|
||||||
|
const hasSuccessfulStorage = backup.storages.some(
|
||||||
|
(storage) => storage.status === "success"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasSuccessfulStorage && backup.status !== "success") {
|
||||||
|
await db
|
||||||
|
.update(drizzleDb.schemas.backup)
|
||||||
|
.set({
|
||||||
|
status: "success",
|
||||||
|
fileSize: fileSize,
|
||||||
|
})
|
||||||
|
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventEmitter.emit('modification', {update: true});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Backup status successfully updated",
|
||||||
|
backupStorage: backupStorage
|
||||||
|
},
|
||||||
|
{status: 200}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in POST for INIT backup:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: "Internal server error"},
|
||||||
|
{status: 500}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -19,7 +19,6 @@ export async function POST(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
eventEmitter.emit('modification', {update: true});
|
|
||||||
|
|
||||||
const agentId = (await params).agentId
|
const agentId = (await params).agentId
|
||||||
const body: BodyResultRestore = await request.json();
|
const body: BodyResultRestore = await request.json();
|
||||||
@@ -72,7 +71,6 @@ export async function POST(
|
|||||||
details: "Restoration successfully updated"
|
details: "Restoration successfully updated"
|
||||||
}
|
}
|
||||||
|
|
||||||
eventEmitter.emit('modification', {update: true});
|
|
||||||
|
|
||||||
return Response.json(response, {status: 200})
|
return Response.json(response, {status: 200})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,21 +2,24 @@ 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} from "@/db/schema/07_database";
|
import {Database, DatabaseWith} from "@/db/schema/07_database";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {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";
|
||||||
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
|
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
|
||||||
import {withUpdatedAt} from "@/db/utils";
|
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";
|
||||||
|
|
||||||
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date) {
|
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date, settings: Setting) {
|
||||||
const databasesResponse = [];
|
const databasesResponse = [];
|
||||||
|
|
||||||
const formatDatabase = (database: Database, backupAction: boolean, restoreAction: boolean, UrlBackup: string) => ({
|
const formatDatabase = (database: DatabaseWith, backupAction: boolean, restoreAction: boolean, UrlBackup: string | null, storages: PingDatabaseStorageChannels[], urlMeta: string | null) => ({
|
||||||
generatedId: database.agentDatabaseId,
|
generatedId: database.agentDatabaseId,
|
||||||
dbms: database.dbms,
|
dbms: database.dbms,
|
||||||
|
storages: storages,
|
||||||
|
encrypt: settings.encryption,
|
||||||
data: {
|
data: {
|
||||||
backup: {
|
backup: {
|
||||||
action: backupAction,
|
action: backupAction,
|
||||||
@@ -25,6 +28,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
restore: {
|
restore: {
|
||||||
action: restoreAction,
|
action: restoreAction,
|
||||||
file: UrlBackup,
|
file: UrlBackup,
|
||||||
|
metaFile: urlMeta
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -32,12 +36,16 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
for (const db of body.databases) {
|
for (const db of body.databases) {
|
||||||
|
|
||||||
const existingDatabase = await dbClient.query.database.findFirst({
|
const existingDatabase = await dbClient.query.database.findFirst({
|
||||||
where: eq(drizzleDb.schemas.database.agentDatabaseId, db.generatedId)
|
where: eq(drizzleDb.schemas.database.agentDatabaseId, db.generatedId),
|
||||||
|
with: {
|
||||||
|
project: true
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let backupAction: boolean = false
|
let backupAction: boolean = false
|
||||||
let restoreAction: boolean = false
|
let restoreAction: boolean = false
|
||||||
let urlBackup: string = ""
|
let urlBackup: string | null = null;
|
||||||
|
let urlMeta: string | null = null
|
||||||
|
|
||||||
if (!existingDatabase) {
|
if (!existingDatabase) {
|
||||||
if (!isUuidv4(db.generatedId)) {
|
if (!isUuidv4(db.generatedId)) {
|
||||||
@@ -63,8 +71,11 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
||||||
if (databaseCreated) {
|
if (databaseCreated) {
|
||||||
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup));
|
const storages = await getDatabaseStorageChannels(databaseCreated.id)
|
||||||
|
|
||||||
|
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
@@ -78,7 +89,6 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
|
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
||||||
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),
|
||||||
@@ -86,7 +96,6 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const restoration = await dbClient.query.restoration.findFirst({
|
const restoration = await dbClient.query.restoration.findFirst({
|
||||||
where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting")),
|
where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting")),
|
||||||
with: {
|
with: {
|
||||||
@@ -94,7 +103,6 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
if (activeBackup && activeBackup.status == "waiting") {
|
if (activeBackup && activeBackup.status == "waiting") {
|
||||||
backupAction = true
|
backupAction = true
|
||||||
|
|
||||||
@@ -107,13 +115,11 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
if (restoration) {
|
if (restoration) {
|
||||||
restoreAction = true
|
restoreAction = true
|
||||||
|
|
||||||
|
|
||||||
if (!restoration.backupStorage || restoration.backupStorage.status != "success" || !restoration.backupStorage.path) {
|
if (!restoration.backupStorage || restoration.backupStorage.status != "success" || !restoration.backupStorage.path) {
|
||||||
restoreAction = false
|
restoreAction = false
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const input: StorageInput = {
|
const input: StorageInput = {
|
||||||
action: "get",
|
action: "get",
|
||||||
data: {
|
data: {
|
||||||
@@ -126,12 +132,26 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const inputMeta: StorageInput = {
|
||||||
|
action: "get",
|
||||||
|
data: {
|
||||||
|
path: `${restoration.backupStorage.path}.meta`,
|
||||||
|
signedUrl: true,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
storageId: restoration.backupStorage.storageChannelId,
|
||||||
|
fileKind: "backups"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await dispatchStorage(input, undefined, restoration.backupStorage.storageChannelId);
|
const result = await dispatchStorage(input, undefined, restoration.backupStorage.storageChannelId);
|
||||||
|
const resultMeta = await dispatchStorage(inputMeta, undefined, restoration.backupStorage.storageChannelId);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
urlBackup = result.url ?? "";
|
urlBackup = result.url ?? null;
|
||||||
|
urlMeta = resultMeta.url ?? null
|
||||||
} else {
|
} else {
|
||||||
await dbClient
|
await dbClient
|
||||||
.update(drizzleDb.schemas.restoration)
|
.update(drizzleDb.schemas.restoration)
|
||||||
@@ -151,53 +171,78 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// const fileName = backupToRestore?.file
|
|
||||||
//
|
|
||||||
// let data: SafeActionResult<string, ZodString, readonly [], {
|
|
||||||
// _errors?: string[] | undefined;
|
|
||||||
// }, readonly [], ServerActionResult<string>, object> | undefined
|
|
||||||
//
|
|
||||||
// try {
|
|
||||||
//
|
|
||||||
// if (settings.storage == "local") {
|
|
||||||
// data = await getFileUrlPresignedLocal({fileName: fileName!})
|
|
||||||
// } else if (settings.storage == "s3") {
|
|
||||||
//
|
|
||||||
// data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`);
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// if (data?.data?.success) {
|
|
||||||
// urlBackup = data.data.value ?? "";
|
|
||||||
// } else {
|
|
||||||
// await dbClient
|
|
||||||
// .update(drizzleDb.schemas.restoration)
|
|
||||||
// .set({status: "failed"})
|
|
||||||
// .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
|
||||||
//
|
|
||||||
// // @ts-ignore
|
|
||||||
// const errorMessage = data?.data?.actionError?.message || "Failed to get presigned URL";
|
|
||||||
// console.error("Restoration failed: ", errorMessage);
|
|
||||||
//
|
|
||||||
// continue;
|
|
||||||
// }
|
|
||||||
// } catch (err) {
|
|
||||||
// console.error("Restoration crashed unexpectedly:", err);
|
|
||||||
// await dbClient
|
|
||||||
// .update(drizzleDb.schemas.restoration)
|
|
||||||
// .set({status: "failed"})
|
|
||||||
// .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
|
||||||
//
|
|
||||||
// continue;
|
|
||||||
// }
|
|
||||||
await dbClient
|
await dbClient
|
||||||
.update(drizzleDb.schemas.restoration)
|
.update(drizzleDb.schemas.restoration)
|
||||||
.set({status: "ongoing"})
|
.set({status: "ongoing"})
|
||||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||||
}
|
}
|
||||||
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup));
|
const storages = await getDatabaseStorageChannels(databaseUpdated.id)
|
||||||
|
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return databasesResponse;
|
return databasesResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type PingDatabaseStorageChannels = {
|
||||||
|
id: string;
|
||||||
|
config: any
|
||||||
|
provider: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDatabaseStorageChannels(databaseId: string): Promise<PingDatabaseStorageChannels[]> {
|
||||||
|
|
||||||
|
const database = await db.query.database.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.database.id, databaseId),
|
||||||
|
with: {
|
||||||
|
project: true,
|
||||||
|
retentionPolicy: true,
|
||||||
|
alertPolicies: true,
|
||||||
|
storagePolicies: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!database) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await db.query.setting.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.setting.name, "system"),
|
||||||
|
with: {storageChannel: true},
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultStorageChannel: PingDatabaseStorageChannels[] = settings?.storageChannel
|
||||||
|
? [{
|
||||||
|
id: settings.storageChannel.id,
|
||||||
|
provider: settings.storageChannel.provider,
|
||||||
|
config: settings.storageChannel.config,
|
||||||
|
}]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
|
||||||
|
const enabledDatabaseStorageChannels = await Promise.all(
|
||||||
|
(database.storagePolicies ?? [])
|
||||||
|
.filter(p => p.enabled)
|
||||||
|
.map(async policy => {
|
||||||
|
const storageChannel = await db.query.storageChannel.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.storageChannel.id, policy.storageChannelId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!storageChannel) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: storageChannel.id,
|
||||||
|
config: storageChannel.config,
|
||||||
|
provider: storageChannel.provider,
|
||||||
|
} as PingDatabaseStorageChannels;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredChannels: PingDatabaseStorageChannels[] = enabledDatabaseStorageChannels.filter(
|
||||||
|
(c): c is PingDatabaseStorageChannels => c !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
return filteredChannels.length > 0 ? filteredChannels : defaultStorageChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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 {eventEmitter} from "@/features/shared/event";
|
||||||
|
import {notFound} from "next/navigation";
|
||||||
|
|
||||||
export type databaseAgent = {
|
export type databaseAgent = {
|
||||||
name: string,
|
name: string,
|
||||||
@@ -26,12 +27,14 @@ export async function POST(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const agentId = (await params).agentId
|
const agentId = (await params).agentId
|
||||||
|
console.log(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)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: "agentId is not a valid uuid"},
|
{error: "agentId is not a valid uuid"},
|
||||||
{status: 500}
|
{status: 500}
|
||||||
@@ -46,7 +49,14 @@ export async function POST(
|
|||||||
message = "Agent not found"
|
message = "Agent not found"
|
||||||
return NextResponse.json({error: message}, {status: 404})
|
return NextResponse.json({error: message}, {status: 404})
|
||||||
}
|
}
|
||||||
const databasesResponse = await handleDatabases(body, agent, lastContact)
|
|
||||||
|
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
||||||
|
if (!settings) {
|
||||||
|
return NextResponse.json({error: "An error occured"}, {status: 404})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const databasesResponse = await handleDatabases(body, agent, lastContact, settings)
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(drizzleDb.schemas.agent)
|
.update(drizzleDb.schemas.agent)
|
||||||
@@ -56,7 +66,6 @@ export async function POST(
|
|||||||
}))
|
}))
|
||||||
.where(eq(drizzleDb.schemas.agent.id, agentId));
|
.where(eq(drizzleDb.schemas.agent.id, agentId));
|
||||||
|
|
||||||
eventEmitter.emit('modification', {update: true});
|
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
agent: {
|
agent: {
|
||||||
@@ -66,6 +75,7 @@ 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);
|
console.error('Error in POST handler:', error);
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import {EventEmitter} from 'events';
|
|
||||||
import {auth} from "@/lib/auth/auth";
|
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";
|
||||||
|
|
||||||
// export const eventEmitter = new EventEmitter();
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
@@ -28,7 +25,6 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
eventEmitter.on('modification', handleModification);
|
eventEmitter.on('modification', handleModification);
|
||||||
|
|
||||||
// Handle client disconnect
|
|
||||||
request.signal.addEventListener('abort', () => {
|
request.signal.addEventListener('abort', () => {
|
||||||
console.log('Client disconnected');
|
console.log('Client disconnected');
|
||||||
controller.close();
|
controller.close();
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import {NextResponse} from "next/server";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import {env} from "@/env.mjs";
|
||||||
|
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const event = body.Event
|
||||||
|
const headers = event.HTTPRequest.Header
|
||||||
|
const uploadLength = headers["X-File-Size"]?.[0];
|
||||||
|
const uploadOffset = headers["Upload-Offset"]?.[0];
|
||||||
|
const status = headers["X-Status"]?.[0];
|
||||||
|
|
||||||
|
console.log(`Upload ID : ${event.Upload.ID} (${uploadOffset}/${uploadLength})`);
|
||||||
|
|
||||||
|
if (status === "success") {
|
||||||
|
if (
|
||||||
|
body.Type === "post-receive" &&
|
||||||
|
event.Upload.SizeIsDeferred === false &&
|
||||||
|
event.Upload.Offset === event.Upload.Size
|
||||||
|
) {
|
||||||
|
const id = event.Upload.ID;
|
||||||
|
const fileName = headers["X-File-Name"]?.[0];
|
||||||
|
const filePath = headers["X-File-Path"]?.[0];
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
return NextResponse.json({error: "Missing X-File-Path"}, {status: 500});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// const uploadDir = path.join(process.cwd(), "/private/uploads/");
|
||||||
|
const uploadDir = path.join(env.PRIVATE_PATH, "/uploads/");
|
||||||
|
|
||||||
|
const oldFilePath = path.join(uploadDir, "tmp", id);
|
||||||
|
const newFilePath = path.join(uploadDir, filePath);
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(newFilePath), {recursive: true});
|
||||||
|
|
||||||
|
let retries = 10;
|
||||||
|
while (!fs.existsSync(oldFilePath)) {
|
||||||
|
if (retries-- === 0) {
|
||||||
|
return NextResponse.json({error: `Upload file not found: ${oldFilePath}`}, {status: 500});
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.renameSync(oldFilePath, newFilePath);
|
||||||
|
|
||||||
|
const infoFilePath = `${oldFilePath}.info`;
|
||||||
|
if (fs.existsSync(infoFilePath)) {
|
||||||
|
fs.unlinkSync(infoFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataHeaderB64 = headers["Upload-Metadata"]?.[0];
|
||||||
|
|
||||||
|
if (metadataHeaderB64) {
|
||||||
|
const metadataHeader = Buffer.from(metadataHeaderB64, "base64").toString("utf-8");
|
||||||
|
if (metadataHeader) {
|
||||||
|
const tomlContent = metadataHeader
|
||||||
|
.split(",")
|
||||||
|
.map((pair) => {
|
||||||
|
const [key, value] = pair.split(" ");
|
||||||
|
const escapedValue = value.replace(/"/g, '\\"');
|
||||||
|
return `${key} = "${escapedValue}"`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
const metaFilePath = `${newFilePath}.meta`;
|
||||||
|
fs.writeFileSync(metaFilePath, tomlContent, "utf-8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NextResponse.json({});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Hook error:", error);
|
||||||
|
return NextResponse.json({error: "Internal server error"}, {status: 500});
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-11
@@ -6,8 +6,11 @@
|
|||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-poppins), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
--font-title: var(--font-poppins), var(--font-poppins), sans-serif;
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--font-author: var(--font-author);
|
||||||
|
--font-poppins: var(--font-poppins);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
@@ -71,7 +74,7 @@
|
|||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.611 0.204 42.149);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.97 0 0);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
@@ -82,7 +85,7 @@
|
|||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.922 0 0);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.922 0 0);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.611 0.204 42.149);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
@@ -90,12 +93,12 @@
|
|||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.611 0.204 42.149);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.611 0.204 42.149);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -105,8 +108,8 @@
|
|||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.205 0 0);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(0.704 0.191 47.132);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.269 0 0);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.269 0 0);
|
||||||
@@ -116,7 +119,7 @@
|
|||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.704 0.191 47.132);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
@@ -124,12 +127,12 @@
|
|||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.205 0 0);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.704 0.191 47.132);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.704 0.191 47.132);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -137,6 +140,18 @@
|
|||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground font-sans;
|
||||||
|
}
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
@apply font-title;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@ import "./globals.css";
|
|||||||
import {Providers} from "./providers";
|
import {Providers} from "./providers";
|
||||||
import {cn} from "@/lib/utils";
|
import {cn} from "@/lib/utils";
|
||||||
import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer";
|
import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer";
|
||||||
import {inter} from "@/fonts/fonts";
|
import {author, geistMono, poppins} from "@/fonts/fonts";
|
||||||
|
|
||||||
const title = process.env.PROJECT_NAME ?? "App Title";
|
const title = process.env.PROJECT_NAME ?? "App Title";
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ export default async function RootLayout({
|
|||||||
<head>
|
<head>
|
||||||
<meta name="apple-mobile-web-app-title" content={title}/>
|
<meta name="apple-mobile-web-app-title" content={title}/>
|
||||||
</head>
|
</head>
|
||||||
<body className={cn(inter.className, "h-full")}>
|
<body className={cn(poppins.variable, author.variable, geistMono.variable, "font-sans h-full")}>
|
||||||
<ConsoleSilencer/>
|
<ConsoleSilencer/>
|
||||||
<Providers>
|
<Providers>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {ThemeProvider} from "@/features/theme/theme-provider";
|
|||||||
import {Toaster} from "@/components/ui/sonner";
|
import {Toaster} from "@/components/ui/sonner";
|
||||||
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
||||||
import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root";
|
import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root";
|
||||||
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
|
||||||
|
|
||||||
|
|
||||||
export type ProviderProps = PropsWithChildren<{}>;
|
export type ProviderProps = PropsWithChildren<{}>;
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
name: portabase-prod
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build:
|
# build:
|
||||||
context: .
|
# context: .
|
||||||
dockerfile: docker/dockerfile/Dockerfile
|
# dockerfile: docker/dockerfile/Dockerfile
|
||||||
target: prod
|
# target: prod
|
||||||
# image: solucetechnologies/portabase:latest
|
image: portabase/portabase:latest
|
||||||
ports:
|
ports:
|
||||||
- '8887:80'
|
- '8887:80'
|
||||||
environment:
|
environment:
|
||||||
TZ: "Europe/Paris"
|
TZ: "Europe/Paris"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
volumes:
|
||||||
|
- portabase-data:/data
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -36,4 +35,5 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
portabase-data:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
|
|||||||
@@ -17,5 +17,19 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
tusd:
|
||||||
|
image: tusproject/tusd:v2.8.0
|
||||||
|
ports:
|
||||||
|
- "1080:8080"
|
||||||
|
command: >
|
||||||
|
-upload-dir /data/uploads/tmp
|
||||||
|
-hooks-http http://localhost:8887/api/tus/hooks
|
||||||
|
-max-size 21474836480
|
||||||
|
-base-path /tus/files/
|
||||||
|
extra_hosts:
|
||||||
|
- "localhost:host-gateway"
|
||||||
|
volumes:
|
||||||
|
- ./private/uploads/tmp:/data/uploads/tmp
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
|
|||||||
@@ -1,53 +1,80 @@
|
|||||||
FROM node:22-alpine AS base
|
#FROM node:22-bullseye AS base
|
||||||
|
FROM --platform=$BUILDPLATFORM node:22-bullseye AS base
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
ca-certificates \
|
||||||
|
bash \
|
||||||
|
tzdata \
|
||||||
|
libc6 \
|
||||||
|
build-essential \
|
||||||
|
g++ \
|
||||||
|
make \
|
||||||
|
autoconf \
|
||||||
|
automake \
|
||||||
|
libtool \
|
||||||
|
git \
|
||||||
|
nginx \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|
||||||
|
RUN mkdir -p /etc/apt/keyrings \
|
||||||
|
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||||||
|
| gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg \
|
||||||
|
&& echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" \
|
||||||
|
> /etc/apt/sources.list.d/pgdg.list
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
postgresql-18 \
|
||||||
|
postgresql-client-18 \
|
||||||
|
postgresql-contrib-18 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV PATH="/usr/lib/postgresql/18/bin:${PATH}"
|
||||||
|
|
||||||
|
|
||||||
|
FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS tusd-base
|
||||||
|
|
||||||
|
ARG TUSD_VERSION=2.8.0
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
RUN git clone https://github.com/tus/tusd.git . \
|
||||||
|
&& git checkout v${TUSD_VERSION}
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \
|
||||||
|
go build -ldflags="-s -w" -o /tusddist/tusd ./cmd/tusd
|
||||||
|
|
||||||
|
FROM scratch AS tusd-dist
|
||||||
|
COPY --from=tusd-base /tusddist/tusd /tusd
|
||||||
|
|
||||||
RUN apk add --update --no-cache \
|
|
||||||
libc6-compat \
|
|
||||||
openssl \
|
|
||||||
tzdata
|
|
||||||
|
|
||||||
FROM base AS build-env
|
FROM base AS build-env
|
||||||
|
|
||||||
RUN apk add --update --no-cache \
|
|
||||||
build-base \
|
|
||||||
g++ \
|
|
||||||
make \
|
|
||||||
libtool \
|
|
||||||
autoconf \
|
|
||||||
automake
|
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
FROM build-env AS deps
|
FROM build-env AS deps
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
|
||||||
RUN pnpm i --frozen-lockfile
|
RUN pnpm i --frozen-lockfile
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
FROM build-env AS dev
|
FROM build-env AS dev
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=tusd-base /usr/local/bin/tusd /usr/local/bin/tusd
|
||||||
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
RUN chmod +x /app/docker/entrypoints/app-dev-entrypoint.sh
|
RUN chmod +x /app/docker/entrypoints/app-dev-entrypoint.sh
|
||||||
|
|
||||||
ENTRYPOINT ["sh","/app/docker/entrypoints/app-dev-entrypoint.sh"]
|
ENTRYPOINT ["sh","/app/docker/entrypoints/app-dev-entrypoint.sh"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
FROM build-env AS builder
|
FROM build-env AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -55,57 +82,46 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
FROM base AS prod
|
FROM base AS prod
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV PORT=80
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
ENV PGDATA=/data/postgres
|
||||||
|
ENV PRIVATE_PATH=/data/private
|
||||||
|
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
RUN mkdir .next
|
|
||||||
RUN chown nextjs:nodejs .next
|
|
||||||
RUN mkdir -p /app/private/uploads
|
|
||||||
RUN chown -R nextjs:nodejs /app/private
|
|
||||||
RUN chown -R nextjs:nodejs /app/public
|
|
||||||
|
|
||||||
COPY --from=builder /app/next.config.ts ./
|
COPY --from=builder /app/next.config.ts ./
|
||||||
COPY --from=builder /app/server ./server
|
|
||||||
COPY --from=builder /app/src ./src
|
|
||||||
|
|
||||||
COPY --from=builder /app/portabase.config.ts ./
|
COPY --from=builder /app/portabase.config.ts ./
|
||||||
COPY --from=builder /app/drizzle.config.ts ./
|
COPY --from=builder /app/drizzle.config.ts ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=1001:1001 /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static
|
||||||
COPY --chown=nextjs:nodejs src/db ./src/db
|
COPY --chown=1001:1001 src/db ./src/db
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
|
||||||
USER root
|
COPY --from=tusd-dist /tusd /usr/local/bin/tusd
|
||||||
|
RUN chmod +x /usr/local/bin/tusd
|
||||||
|
|
||||||
|
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
#RUN mkdir -p .next /app/private/uploads \
|
||||||
|
# && chown -R nextjs:nodejs .next /app/private /app/public
|
||||||
|
|
||||||
|
RUN mkdir -p .next /data/private/uploads \
|
||||||
|
&& chown -R nextjs:nodejs .next /data/private /app/public
|
||||||
|
|
||||||
|
|
||||||
|
USER root
|
||||||
COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh
|
COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh
|
||||||
RUN chmod +x /app/app-prod-entrypoint.sh
|
RUN chmod +x /app/app-prod-entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
ENV PORT=80
|
|
||||||
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
|
||||||
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"]
|
ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"]
|
||||||
|
|||||||
@@ -7,8 +7,77 @@ else
|
|||||||
echo "[WARN] No TZ provided, using default container timezone"
|
echo "[WARN] No TZ provided, using default container timezone"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
pnpm run start
|
POSTGRES_BIN=$(ls -d /usr/lib/postgresql/*/bin | head -n 1)
|
||||||
#node server.js
|
|
||||||
|
if [ -z "$POSTGRES_BIN" ]; then
|
||||||
|
echo "PostgreSQL binaries not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PATH="$POSTGRES_BIN:$PATH"
|
||||||
|
|
||||||
|
if [ -z "$DATABASE_URL" ]; then
|
||||||
|
echo "[INFO] No DATABASE_URL provided, starting internal Postgres..."
|
||||||
|
|
||||||
|
mkdir -p "$PGDATA"
|
||||||
|
chown -R postgres:postgres "$PGDATA"
|
||||||
|
|
||||||
|
if [ ! -f "$PGDATA/PG_VERSION" ]; then
|
||||||
|
echo "[INFO] Initializing database cluster..."
|
||||||
|
if ! su postgres -c "initdb -D '$PGDATA'" > /dev/null 2>&1; then
|
||||||
|
echo "[ERROR] initdb failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! su postgres -c "pg_ctl -D '$PGDATA' \
|
||||||
|
-o \"-c listen_addresses='localhost' -c logging_collector=on\" \
|
||||||
|
-l $PGDATA/postgres.log -w start" > /dev/null 2>&1; then
|
||||||
|
echo "[ERROR] PostgreSQL failed to start"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
until su postgres -c "pg_isready -h 127.0.0.1 -p 5432" > /dev/null 2>&1; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[INFO] PostgreSQL server is up and accepting connections"
|
||||||
|
|
||||||
|
DB_USER="${POSTGRES_USER:-portabase_user}"
|
||||||
|
DB_PASS="${POSTGRES_PASSWORD:-JaB6b1SUtIWYvt7srnOt}"
|
||||||
|
DB_NAME="${POSTGRES_DB:-portabase_db}"
|
||||||
|
|
||||||
|
USER_EXISTS=$(su postgres -c "psql -tAc \"SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'\"" 2>/dev/null)
|
||||||
|
if [ "$USER_EXISTS" != "1" ]; then
|
||||||
|
if ! su postgres -c "psql -c \"CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';\"" > /dev/null 2>&1; then
|
||||||
|
echo "[ERROR] Failed creating user"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
DB_EXISTS=$(su postgres -c "psql -tAc \"SELECT 1 FROM pg_database WHERE datname='$DB_NAME'\"" 2>/dev/null)
|
||||||
|
if [ "$DB_EXISTS" != "1" ]; then
|
||||||
|
if ! su postgres -c "psql -c \"CREATE DATABASE $DB_NAME OWNER $DB_USER;\"" > /dev/null 2>&1; then
|
||||||
|
echo "[ERROR] Failed creating database"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
export DATABASE_URL="postgres://$DB_USER:$DB_PASS@127.0.0.1:5432/$DB_NAME"
|
||||||
|
|
||||||
|
echo "[SUCCESS] Internal PostgreSQL started successfully"
|
||||||
|
echo "[SUCCESS] Database: $DB_NAME | User: $DB_USER | Host: 127.0.0.1:5432"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
mkdir -p /data/private/uploads/tmp
|
||||||
|
echo "▶ Starting tusd server..."
|
||||||
|
tusd --base-path /tus/files/ --upload-dir /data/private/uploads/tmp --hooks-http http://127.0.0.1:3000/api/tus/hooks --port 1080 --max-size 21474836480 &
|
||||||
|
|
||||||
|
echo "▶ Starting Next.js server..."
|
||||||
|
PORT=3000 node server.js &
|
||||||
|
|
||||||
|
echo "▶ Starting nginx..."
|
||||||
|
exec nginx -g "daemon off;"
|
||||||
|
|
||||||
exec "$@"
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
events {}
|
||||||
|
|
||||||
|
http {
|
||||||
|
client_max_body_size 20G;
|
||||||
|
ignore_invalid_headers off;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
location /tus/ {
|
||||||
|
proxy_pass http://127.0.0.1:1080/tus/;
|
||||||
|
|
||||||
|
proxy_pass_request_headers on;
|
||||||
|
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type {NextConfig} from "next";
|
import type {NextConfig} from "next";
|
||||||
import {PORTABASE_DEFAULT_SETTINGS} from "./portabase.config";
|
import {PORTABASE_DEFAULT_SETTINGS} from "./portabase.config";
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
|
|
||||||
function buildCSPHeader(): string {
|
function buildCSPHeader(): string {
|
||||||
const {CSP} = PORTABASE_DEFAULT_SETTINGS.SECURITY;
|
const {CSP} = PORTABASE_DEFAULT_SETTINGS.SECURITY;
|
||||||
@@ -47,6 +49,17 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
proxyClientMaxBodySize: '10gb',
|
proxyClientMaxBodySize: '10gb',
|
||||||
},
|
},
|
||||||
|
async rewrites() {
|
||||||
|
if (!isDev) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/tus/:path*",
|
||||||
|
destination: "http://localhost:1080/tus/:path*",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
+81
-82
@@ -1,12 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "portabase",
|
"name": "portabase",
|
||||||
"version": "1.2.5-rc.6",
|
"version": "1.2.7-rc.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_ENV=development tsx watch server/server.ts",
|
"dev": "next dev --turbopack -p 8887",
|
||||||
"build": "next build --experimental-build-mode compile",
|
"build": "next build --experimental-build-mode compile",
|
||||||
"start": "NODE_ENV=production tsx server/server.ts",
|
"start": "next start",
|
||||||
|
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"email": "email dev --dir ./src/components/emails",
|
"email": "email dev --dir ./src/components/emails",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
@@ -15,52 +14,52 @@
|
|||||||
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts"
|
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.0.1",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.10",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.13",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.6",
|
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||||
"@radix-ui/react-avatar": "^1.1.9",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.1",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-collapsible": "^1.1.10",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-context-menu": "^2.2.14",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.13",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.14",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-hover-card": "^1.1.13",
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@radix-ui/react-label": "^2.1.6",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-menubar": "^1.1.14",
|
"@radix-ui/react-menubar": "^1.1.16",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.12",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-popover": "^1.1.13",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-progress": "^1.1.6",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-radio-group": "^1.3.6",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.8",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.4",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.6",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slider": "^1.3.4",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.2",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.4",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.11",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toast": "^1.2.13",
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
"@radix-ui/react-toggle": "^1.1.8",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.9",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.6",
|
"@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.4",
|
"@t3-oss/env-nextjs": "^0.13.10",
|
||||||
"@tanstack/react-query": "^5.76.1",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.22",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@zenstackhq/runtime": "2.14.2",
|
"@zenstackhq/runtime": "2.14.2",
|
||||||
"argon2": "^0.43.0",
|
"argon2": "^0.43.1",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-auth": "1.4.5",
|
"better-auth": "1.4.5",
|
||||||
"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.6",
|
"dockerode": "^4.0.9",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.6.1",
|
||||||
"drizzle-orm": "^0.43.1",
|
"drizzle-orm": "^0.43.1",
|
||||||
"drizzle-zod": "^0.7.1",
|
"drizzle-zod": "^0.7.1",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
@@ -68,65 +67,65 @@
|
|||||||
"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.5",
|
"minio": "^8.0.6",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.34.0",
|
||||||
"next": "16.1.5",
|
"next": "16.1.5",
|
||||||
"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.1",
|
"node-forge": "^1.3.3",
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.13",
|
||||||
"npm-check-updates": "^18.0.1",
|
"npm-check-updates": "^18.3.1",
|
||||||
"pg": "^8.16.0",
|
"pg": "^8.18.0",
|
||||||
"prettier": "^3.8.0",
|
"prettier": "^3.8.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.4",
|
||||||
"react-day-picker": "9.7.0",
|
"react-day-picker": "9.7.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.4",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.4.1",
|
||||||
"react-email": "^4.0.13",
|
"react-email": "^4.3.2",
|
||||||
"react-hook-form": "^7.56.3",
|
"react-hook-form": "^7.71.1",
|
||||||
"react-qr-code": "^2.0.18",
|
"react-qr-code": "^2.0.18",
|
||||||
"react-resizable-panels": "^3.0.2",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-twc": "^1.4.2",
|
"react-twc": "^1.5.1",
|
||||||
"react-use-measure": "^2.1.7",
|
"react-use-measure": "^2.1.7",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.4",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.3",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.3",
|
||||||
"sonner": "^2.0.3",
|
"sonner": "^2.0.7",
|
||||||
"swiper": "^12.0.3",
|
"swiper": "^12.1.0",
|
||||||
"tailwind-merge": "^3.3.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"ws": "^8.18.2",
|
"ws": "^8.19.0",
|
||||||
"zod": "^3.24.4"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/react": "^6.0.0",
|
"@iconify/react": "^6.0.2",
|
||||||
"@react-email/preview-server": "4.3.2",
|
"@react-email/preview-server": "4.3.2",
|
||||||
"@react-email/render": "^2.0.1",
|
"@react-email/render": "^2.0.4",
|
||||||
"@tailwindcss/postcss": "^4.1.7",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||||
"@types/node": "^22.15.18",
|
"@types/node": "^22.19.11",
|
||||||
"@types/node-forge": "^1",
|
"@types/node-forge": "^1.3.14",
|
||||||
"@types/pg": "^8.15.2",
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19.1.4",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@zenstackhq/openapi": "^2.14.2",
|
"@zenstackhq/openapi": "^2.22.1",
|
||||||
"@zenstackhq/tanstack-query": "^2.14.2",
|
"@zenstackhq/tanstack-query": "^2.22.2",
|
||||||
"baseline-browser-mapping": "^2.9.19",
|
"baseline-browser-mapping": "^2.9.19",
|
||||||
"drizzle-kit": "^0.31.1",
|
"drizzle-kit": "^0.31.9",
|
||||||
"esbuild": "^0.27.2",
|
"esbuild": "^0.27.3",
|
||||||
"eslint": "^9.39.0",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-next": "^16.0.1",
|
"eslint-config-next": "^16.1.6",
|
||||||
"eslint-plugin-tailwindcss": "^3.18.0",
|
"eslint-plugin-tailwindcss": "^3.18.2",
|
||||||
"framer-motion": "^12.24.7",
|
"framer-motion": "^12.34.0",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.7",
|
"tailwindcss": "^4.1.18",
|
||||||
"tsx": "^4.19.4",
|
"tsx": "^4.21.0",
|
||||||
"tw-animate-css": "^1.2.9",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.9.3",
|
||||||
"zenstack": "2.14.2"
|
"zenstack": "2.14.2"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316"
|
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+923
-861
File diff suppressed because it is too large
Load Diff
+1
-3
@@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
export interface AuthProviderConfig {
|
export interface AuthProviderConfig {
|
||||||
id: "google" | "github" | "credential";
|
id: "google" | "github" | "credential";
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
@@ -64,7 +62,7 @@ export const PORTABASE_DEFAULT_SETTINGS = {
|
|||||||
FORM_ACTION: ["'self'"],
|
FORM_ACTION: ["'self'"],
|
||||||
FRAME_ANCESTORS: ["'none'"],
|
FRAME_ANCESTORS: ["'none'"],
|
||||||
BLOCK_ALL_MIXED_CONTENT: false,
|
BLOCK_ALL_MIXED_CONTENT: false,
|
||||||
UPGRADE_INSECURE_REQUESTS: true,
|
UPGRADE_INSECURE_REQUESTS: process.env.PROJECT_URL?.startsWith("https://") || false,
|
||||||
},
|
},
|
||||||
PERMISSIONS_POLICY: {
|
PERMISSIONS_POLICY: {
|
||||||
CAMERA: ["()"],
|
CAMERA: ["()"],
|
||||||
|
|||||||
@@ -53,11 +53,13 @@ function checkRouteExists(pathname: string) {
|
|||||||
const routePatterns = [
|
const routePatterns = [
|
||||||
/^\/api\/agent\/[^/]+\/status\/?$/,
|
/^\/api\/agent\/[^/]+\/status\/?$/,
|
||||||
/^\/api\/agent\/[^/]+\/backup\/?$/,
|
/^\/api\/agent\/[^/]+\/backup\/?$/,
|
||||||
|
/^\/api\/agent\/[^/]+\/backup\/upload\/init\/?$/,
|
||||||
|
/^\/api\/agent\/[^/]+\/backup\/upload\/status\/?$/,
|
||||||
/^\/api\/agent\/[^/]+\/restore\/?$/,
|
/^\/api\/agent\/[^/]+\/restore\/?$/,
|
||||||
/^\/api\/files\/images\/[^/]+\/?$/,
|
/^\/api\/files\/images\/[^/]+\/?$/,
|
||||||
/^\/api\/files\/backups\/?$/,
|
/^\/api\/files\/backups\/?$/,
|
||||||
|
/^\/api\/tus\/hooks\/?$/,
|
||||||
/^\/api\/events\/?$/,
|
/^\/api\/events\/?$/,
|
||||||
/^\/api\/init\/?$/,
|
|
||||||
/^\/api\/config\/?$/,
|
/^\/api\/config\/?$/,
|
||||||
/^\/api\/google\/drive\/callback\/?$/,
|
/^\/api\/google\/drive\/callback\/?$/,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ set -e
|
|||||||
|
|
||||||
if [ -z "$1" ]; then
|
if [ -z "$1" ]; then
|
||||||
echo "Usage: ./release <version>"
|
echo "Usage: ./release <version>"
|
||||||
echo "Example: ./release v1.0.0"
|
echo "Example: ./release 1.0.0"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+19
-9
@@ -1,6 +1,7 @@
|
|||||||
import next from "next";
|
import next from "next";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import {mountApi} from "./api";
|
import {mountApi} from "./api";
|
||||||
|
import http from "http";
|
||||||
|
|
||||||
const port = Number(process.env.PORT) || 8887;
|
const port = Number(process.env.PORT) || 8887;
|
||||||
const dev = process.env.NODE_ENV !== "production";
|
const dev = process.env.NODE_ENV !== "production";
|
||||||
@@ -17,11 +18,17 @@ async function start() {
|
|||||||
|
|
||||||
await nextApp.prepare();
|
await nextApp.prepare();
|
||||||
|
|
||||||
const server = express();
|
const app = express();
|
||||||
|
|
||||||
mountApi(server);
|
app.use((req, res, next) => {
|
||||||
|
req.setTimeout(0);
|
||||||
|
res.setTimeout(0);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
server.use((req, res, next) => {
|
mountApi(app);
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
if (req.path.startsWith('/services/v1')) return next();
|
if (req.path.startsWith('/services/v1')) return next();
|
||||||
handle(req, res).catch((err) => {
|
handle(req, res).catch((err) => {
|
||||||
console.error('Next.js App Router error:', err);
|
console.error('Next.js App Router error:', err);
|
||||||
@@ -29,13 +36,16 @@ async function start() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const server = http.createServer(app);
|
||||||
|
|
||||||
|
server.setTimeout(0);
|
||||||
|
server.headersTimeout = 0;
|
||||||
|
server.requestTimeout = 0;
|
||||||
|
server.keepAliveTimeout = 0;
|
||||||
|
|
||||||
server.listen(port, "0.0.0.0", () => {
|
server.listen(port, "0.0.0.0", () => {
|
||||||
console.log(
|
console.log(`NEXT APP → http://localhost:${port}`);
|
||||||
`NEXT APP → http://localhost:${port}`
|
console.log(`API → http://localhost:${port}/services/v1`);
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`API → http://localhost:${port}/services/v1`
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { motion, useInView, type SpringOptions, type UseInViewOptions } from 'mo
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const githubButtonVariants = cva(
|
const githubButtonVariants = cva(
|
||||||
'cursor-pointer relative overflow-hidden will-change-transform backface-visibility-hidden transform-gpu transition-transform duration-200 ease-out hover:scale-105 group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0',
|
'cursor-pointer relative overflow-hidden will-change-transform backface-visibility-hidden transform-gpu group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex w-full min-w-0 rounded-md border bg-transparent px-4 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className
|
className
|
||||||
|
|||||||
@@ -374,7 +374,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="sidebar-content"
|
data-slot="sidebar-content"
|
||||||
data-sidebar="content"
|
data-sidebar="content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto scrollbar-hide group-data-[collapsible=icon]:overflow-hidden",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -41,9 +41,6 @@ export const AuthLogoSection = () => {
|
|||||||
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="absolute bottom-8 right-5 text-sm text-muted-foreground" style={style}>
|
|
||||||
v{env.NEXT_PUBLIC_PROJECT_VERSION}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export const ForgotPasswordForm = (props: ForgotPasswordFormProps) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-y-6 w-full">
|
<div className="flex flex-col items-center gap-y-6 w-full">
|
||||||
<ButtonWithLoading className="mt-2 w-full" isPending={mutation.isPending}>
|
<ButtonWithLoading className="mt-2 w-full h-11" isPending={mutation.isPending}>
|
||||||
Send reset link
|
Send reset link
|
||||||
</ButtonWithLoading>
|
</ButtonWithLoading>
|
||||||
<Link href="/login" className="group flex items-center text-sm hover:underline">
|
<Link href="/login" className="group flex items-center text-sm hover:underline">
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export const LoginForm = (props: loginFormProps) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email Address</FormLabel>
|
<FormLabel>Email Address</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input autoComplete="email" autoFocus placeholder="exemple@portabase.io" {...field} />
|
<Input autoComplete="email" autoFocus placeholder="example@portabase.io" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -113,7 +113,7 @@ export const LoginForm = (props: loginFormProps) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<ButtonWithLoading className="mt-2" isPending={mutation.isPending}>
|
<ButtonWithLoading className="mt-2 h-11" isPending={mutation.isPending}>
|
||||||
Login
|
Login
|
||||||
</ButtonWithLoading>
|
</ButtonWithLoading>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export const ResetPasswordForm = (props: ResetPasswordFormProps) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-y-6 w-full">
|
<div className="flex flex-col items-center gap-y-6 w-full">
|
||||||
<ButtonWithLoading className="mt-2 w-full" isPending={mutation.isPending}>
|
<ButtonWithLoading className="mt-2 w-full h-11" isPending={mutation.isPending}>
|
||||||
Reset
|
Reset
|
||||||
</ButtonWithLoading>
|
</ButtonWithLoading>
|
||||||
<Link href="/login" className="group flex items-center text-sm hover:underline">
|
<Link href="/login" className="group flex items-center text-sm hover:underline">
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const RegisterForm = (props: registerFormProps) => {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="grid gap-2 text-center mb-2">
|
<div className="grid gap-2 text-center mb-2">
|
||||||
<h1 className="text-3xl font-bold">Create an account</h1>
|
<h1 className="text-3xl font-bold">Create an account</h1>
|
||||||
<p className="text-balance text-muted-foreground">Enter your informations bellow to register</p>
|
<p className="text-balance text-muted-foreground">Enter your informations below to register</p>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -88,7 +88,7 @@ export const RegisterForm = (props: registerFormProps) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="exemple@portabase.io" {...field} />
|
<Input placeholder="example@portabase.io" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -138,7 +138,7 @@ export const RegisterForm = (props: registerFormProps) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" disabled={mutation.isPending}>
|
<Button type="submit" className="h-11" disabled={mutation.isPending}>
|
||||||
Sign up
|
Sign up
|
||||||
</Button>
|
</Button>
|
||||||
<div className="mt-4 text-center text-sm">
|
<div className="mt-4 text-center text-sm">
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const ResetPasswordForm = ({token}: ResetPasswordFormProps) => {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="grid gap-2 text-center mb-2">
|
<div className="grid gap-2 text-center mb-2">
|
||||||
<h1 className="text-3xl font-bold">Reset Password</h1>
|
<h1 className="text-3xl font-bold">Reset Password</h1>
|
||||||
<p className="text-balance text-muted-foreground">Fill information bellow to change your
|
<p className="text-balance text-muted-foreground">Fill information below to change your
|
||||||
password</p>
|
password</p>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import {Button, ButtonVariantsProps} from "@/components/ui/button";
|
import {Button, ButtonVariantsProps} from "@/components/ui/button";
|
||||||
import {useState} from "react";
|
import {ReactNode, useState} from "react";
|
||||||
import {Loader2} from "lucide-react";
|
import {Loader2} from "lucide-react";
|
||||||
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
|
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
|
||||||
import {cn} from "@/lib/utils";
|
import {cn} from "@/lib/utils";
|
||||||
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip";
|
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
export type ButtonWithConfirmProps = {
|
export type ButtonWithConfirmProps = {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
button: {
|
button?: {
|
||||||
main: {
|
main: {
|
||||||
className?: string;
|
className?: string;
|
||||||
type?: "button" | "submit" | "reset" | undefined;
|
type?: "button" | "submit" | "reset" | undefined;
|
||||||
@@ -37,6 +37,11 @@ export type ButtonWithConfirmProps = {
|
|||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
children?: ReactNode;
|
||||||
|
onConfirm?: (e: React.MouseEvent) => void;
|
||||||
|
onCancel?: (e: React.MouseEvent) => void;
|
||||||
|
confirmButtonText?: string;
|
||||||
|
cancelButtonText?: string;
|
||||||
isPending?: boolean;
|
isPending?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,41 +49,86 @@ export type ButtonWithConfirmProps = {
|
|||||||
export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
|
export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
|
||||||
const [isConfirming, setIsConfirming] = useState(false);
|
const [isConfirming, setIsConfirming] = useState(false);
|
||||||
|
|
||||||
return (
|
const isLegacy = !!props.button;
|
||||||
<Popover open={isConfirming} onOpenChange={!props.button.main.disabled ? setIsConfirming : undefined}>
|
const isDisabled = isLegacy ? !!props.button?.main.disabled : false;
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
const handleConfirm = (e: React.MouseEvent) => {
|
||||||
<PopoverTrigger asChild>
|
e.preventDefault();
|
||||||
<span
|
e.stopPropagation();
|
||||||
className={cn(
|
if (isLegacy) {
|
||||||
"inline-flex",
|
props.button?.confirm.onClick?.();
|
||||||
props.button.main.disabled ? "cursor-not-allowed" : ""
|
} else {
|
||||||
)}
|
props.onConfirm?.(e);
|
||||||
role="button">
|
}
|
||||||
|
setIsConfirming(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isLegacy) {
|
||||||
|
props.button?.cancel.onClick?.();
|
||||||
|
} else {
|
||||||
|
props.onCancel?.(e);
|
||||||
|
}
|
||||||
|
setIsConfirming(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerContent = isLegacy ? (
|
||||||
<Button
|
<Button
|
||||||
type={props.button.main.type}
|
type={props.button?.main.type}
|
||||||
disabled={!!props.button.main.disabled}
|
disabled={isDisabled}
|
||||||
variant={props.button.main.variant ?? "default"}
|
variant={props.button?.main.variant ?? "default"}
|
||||||
size={props.button.main.size ?? "default"}
|
size={props.button?.main.size ?? "default"}
|
||||||
className={props.button.main.className}
|
className={props.button?.main.className}
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
if (!props.button.main.disabled) setIsConfirming(true);
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!isDisabled) setIsConfirming(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
|
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
|
||||||
{props.button.main.icon}
|
{props.button?.main.icon}
|
||||||
{props.button.main.text && <span>{props.button.main.text}</span>}
|
{props.button?.main.text && <span>{props.button.main.text}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
) : (
|
||||||
</PopoverTrigger>
|
<div onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsConfirming(true);
|
||||||
|
}}>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const withTooltip = isLegacy && props.button?.main.tooltipText && isDisabled ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div>{triggerContent}</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
{props.button.main.tooltipText && props.button.main.disabled && (
|
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>{props.button.main.tooltipText}</p>
|
<p>{props.button?.main.tooltipText}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
)}
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<PopoverContent className="w-80">
|
</TooltipProvider>
|
||||||
|
) : triggerContent;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={isConfirming} onOpenChange={setIsConfirming}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
{withTooltip}
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-80"
|
||||||
|
onPointerDownOutside={(e) => e.preventDefault()}
|
||||||
|
onInteractOutside={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseMove={(e) => e.stopPropagation()}
|
||||||
|
onMouseEnter={(e) => e.stopPropagation()}
|
||||||
|
onMouseLeave={(e) => e.stopPropagation()}
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium leading-none">{props.title}</h4>
|
<h4 className="font-medium leading-none">{props.title}</h4>
|
||||||
@@ -86,29 +136,22 @@ export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={handleConfirm}
|
||||||
if (isConfirming && props.button.confirm.onClick) {
|
variant={isLegacy ? (props.button?.confirm.variant ?? "default") : "default"}
|
||||||
props.button.confirm.onClick();
|
size={isLegacy ? (props.button?.main.size ?? "default") : "default"}
|
||||||
setIsConfirming(false);
|
className={cn(isLegacy ? props.button?.main.className : "", "w-full")}
|
||||||
} else {
|
|
||||||
setIsConfirming(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
variant={props.button.confirm.variant ? props.button.confirm.variant : "default"}
|
|
||||||
size={props.button.main.size ?? "default"}
|
|
||||||
className={cn(props.button.main.className, "w-full")}
|
|
||||||
>
|
>
|
||||||
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
|
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
|
||||||
{props.button.confirm.icon ? props.button.confirm.icon : null}
|
{isLegacy && props.button?.confirm.icon}
|
||||||
<span>{props.button.confirm.text}</span>
|
<span>{isLegacy ? props.button?.confirm.text : (props.confirmButtonText ?? "Confirm")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={props.button.cancel.variant ? props.button.cancel.variant : "default"}
|
variant={isLegacy ? (props.button?.cancel.variant ?? "outline") : "outline"}
|
||||||
onClick={props.button.cancel.onClick ? () => props.button.cancel.onClick!() : () => setIsConfirming(false)}
|
onClick={handleCancel}
|
||||||
className={cn(props.button.main.className, "w-full")}
|
className={cn(isLegacy ? props.button?.main.className : "", "w-full")}
|
||||||
size={props.button.main.size ?? "default"}
|
size={isLegacy ? (props.button?.main.size ?? "default") : "default"}
|
||||||
>
|
>
|
||||||
Cancel
|
{isLegacy ? (props.button?.cancel.text ?? "Cancel") : (props.cancelButtonText ?? "Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,4 +159,3 @@ export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
|
|||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export type ButtonWithLoadingProps = {
|
|||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
variant?: keyof VariantButton;
|
variant?: keyof VariantButton;
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick?: () => void;
|
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
isPending?: boolean;
|
isPending?: boolean;
|
||||||
size?: keyof SizeButton;
|
size?: keyof SizeButton;
|
||||||
} & ButtonHTMLAttributes<HTMLButtonElement>;
|
} & ButtonHTMLAttributes<HTMLButtonElement>;
|
||||||
@@ -100,7 +100,7 @@ export const ButtonWithLoading = ({
|
|||||||
}: ButtonWithLoadingProps) => {
|
}: ButtonWithLoadingProps) => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onClick?.()}
|
onClick={(e) => onClick?.(e)}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
className={className}
|
className={className}
|
||||||
size={size}
|
size={size}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export const CopyButton = (props: CopyButtonProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
// className={cn(buttonVariants({size: "sm"}))}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
copyToClipboardWithMeta(value);
|
copyToClipboardWithMeta(value);
|
||||||
setHasCopied(true);
|
setHasCopied(true);
|
||||||
|
|||||||
@@ -1,21 +1,46 @@
|
|||||||
import { PropsWithChildren } from "react";
|
"use client";
|
||||||
|
|
||||||
|
import {PropsWithChildren, useState} from "react";
|
||||||
import {Button} from "@/components/ui/button";
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {Check, Copy, Terminal} from "lucide-react";
|
||||||
|
import {copyToClipboardWithMeta} from "@/components/wrappers/common/button/copy-button";
|
||||||
|
import {cn} from "@/lib/utils";
|
||||||
|
|
||||||
export type CodeSnippetProps = PropsWithChildren<{
|
export type CodeSnippetProps = PropsWithChildren<{
|
||||||
code: string;
|
code: string;
|
||||||
|
title?: string;
|
||||||
|
className?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const CodeSnippet = (props: CodeSnippetProps) => {
|
export const CodeSnippet = (props: CodeSnippetProps) => {
|
||||||
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
await copyToClipboardWithMeta(props.code);
|
||||||
|
setIsCopied(true);
|
||||||
|
setTimeout(() => setIsCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-background">
|
<div className={cn("relative group rounded-md bg-muted/50 border overflow-hidden", props.className)}>
|
||||||
<div className="flex items-center justify-between border-b bg-muted px-4 py-2">
|
{props.title && (
|
||||||
<div className="text-sm font-medium">.env</div>
|
<div className="flex items-center px-4 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
|
||||||
<Button variant="ghost" size="icon" className="hover:bg-muted/50 text-muted-foreground"></Button>
|
{props.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 font-mono text-sm leading-6 text-foreground truncate">
|
)}
|
||||||
<pre className="language-javascript overflow-x-auto">
|
<div className="flex items-center p-4">
|
||||||
<code className="mb-6">{props.code}</code>
|
<pre className="flex-1 overflow-x-auto font-mono text-sm leading-relaxed">
|
||||||
|
<code className="break-all whitespace-pre-wrap">{props.code}</code>
|
||||||
</pre>
|
</pre>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="ml-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
{isCopied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||||
|
<span className="sr-only">Copy code</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const AgentRegistrationDialog = (props: agentRegistrationDialogProps) =>
|
|||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="name" className="text-right">
|
<Label htmlFor="name" className="text-right">
|
||||||
EDGE KEY
|
CONNECTION KEY
|
||||||
</Label>
|
</Label>
|
||||||
<Input id="name" value="Pedro Duarte" readOnly className="col-span-3" />
|
<Input id="name" value="Pedro Duarte" readOnly className="col-span-3" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const GitHubStarsButtonCustom = () => {
|
|||||||
<GithubButton
|
<GithubButton
|
||||||
initialStars={0}
|
initialStars={0}
|
||||||
targetStars={stars}
|
targetStars={stars}
|
||||||
label="Github Stars"
|
label=""
|
||||||
size="sm"
|
size="sm"
|
||||||
separator={true}
|
separator={true}
|
||||||
roundStars={true}
|
roundStars={true}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export type paginationNavigationProps = {
|
|||||||
export const PaginationNavigation = (props: paginationNavigationProps) => {
|
export const PaginationNavigation = (props: paginationNavigationProps) => {
|
||||||
const { className, totalPages, currentPage, goToPage, goToPrevPage, goToNextPage, maxVisiblePages = 3 } = props;
|
const { className, totalPages, currentPage, goToPage, goToPrevPage, goToNextPage, maxVisiblePages = 3 } = props;
|
||||||
|
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pagination className={cn("", className)}>
|
<Pagination className={cn("", className)}>
|
||||||
<PaginationContent>
|
<PaginationContent>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table";
|
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table";
|
||||||
import {Input} from "@/components/ui/input";
|
import {Input} from "@/components/ui/input";
|
||||||
|
|
||||||
import {ReactNode, useEffect, useState} from "react";
|
import {ReactNode, useMemo, useState} from "react";
|
||||||
import {TablePagination} from "./table-pagination";
|
import {TablePagination} from "./table-pagination";
|
||||||
import {Checkbox} from "@/components/ui/checkbox";
|
import {Checkbox} from "@/components/ui/checkbox";
|
||||||
import {Button} from "@/components/ui/button";
|
import {Button} from "@/components/ui/button";
|
||||||
@@ -63,35 +63,38 @@ export function DataTable<TData, TValue>({
|
|||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
const [rowSelection, setRowSelection] = useState({});
|
const [rowSelection, setRowSelection] = useState({});
|
||||||
|
|
||||||
useEffect(() => {
|
const finalColumns = useMemo(() => {
|
||||||
setRowSelection({});
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
if (enableSelect && data.length > 0) {
|
|
||||||
const selectColumnExists = columns.some((column) => column.id === "select");
|
const selectColumnExists = columns.some((column) => column.id === "select");
|
||||||
|
if (enableSelect && data.length > 0 && !selectColumnExists) {
|
||||||
if (!selectColumnExists) {
|
return [
|
||||||
columns.unshift({
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
header: ({table}) => (
|
header: ({table}: {table: any}) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
|
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
|
||||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
aria-label="Select all"
|
aria-label="Select all"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({row}) => <Checkbox checked={row.getIsSelected()}
|
cell: ({row}: {row: any}) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
aria-label="Select row"/>,
|
aria-label="Select row"
|
||||||
|
/>
|
||||||
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
});
|
},
|
||||||
}
|
...columns,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
return columns;
|
||||||
|
}, [columns, enableSelect, data.length]);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns: finalColumns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
@@ -99,6 +102,10 @@ export function DataTable<TData, TValue>({
|
|||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getRowId: (row: any) => row.id || row.uuid,
|
||||||
|
autoResetPageIndex: false,
|
||||||
|
autoResetExpanded: false,
|
||||||
|
enableRowSelection: true,
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
columnFilters,
|
columnFilters,
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ interface tablePaginationProps {
|
|||||||
export function TablePagination(props: tablePaginationProps) {
|
export function TablePagination(props: tablePaginationProps) {
|
||||||
const {className, table, maxVisiblePages = 3, pageSizeOptions = [10, 20, 30, 40, 50]} = props;
|
const {className, table, maxVisiblePages = 3, pageSizeOptions = [10, 20, 30, 40, 50]} = props;
|
||||||
|
|
||||||
|
const totalPages = table.getPageCount();
|
||||||
|
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("flex gap-x-4", className)}
|
className={cn("flex gap-x-4", className)}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
ChannelOrganisationForm
|
ChannelOrganisationForm
|
||||||
} from "@/components/wrappers/dashboard/admin/channels/organization/channels-organization-form";
|
} from "@/components/wrappers/dashboard/admin/channels/organization/channels-organization-form";
|
||||||
|
|
||||||
|
|
||||||
type ChannelAddModalProps = {
|
type ChannelAddModalProps = {
|
||||||
channel?: NotificationChannelWith | StorageChannelWith
|
channel?: NotificationChannelWith | StorageChannelWith
|
||||||
organization?: OrganizationWithMembers;
|
organization?: OrganizationWithMembers;
|
||||||
|
|||||||
+4
-3
@@ -34,6 +34,7 @@ import {
|
|||||||
addNotificationChannelAction, updateNotificationChannelAction
|
addNotificationChannelAction, updateNotificationChannelAction
|
||||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action";
|
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action";
|
||||||
|
|
||||||
|
|
||||||
type NotifierFormProps = {
|
type NotifierFormProps = {
|
||||||
onSuccessAction?: () => void;
|
onSuccessAction?: () => void;
|
||||||
organization?: OrganizationWithMembers;
|
organization?: OrganizationWithMembers;
|
||||||
@@ -54,7 +55,7 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset(defaultValues ? {...defaultValues} : {});
|
form.reset(defaultValues ? {...defaultValues} : {enabled: true});
|
||||||
}, [defaultValues]);
|
}, [defaultValues]);
|
||||||
|
|
||||||
const mutationAddNotificationChannel = useMutation({
|
const mutationAddNotificationChannel = useMutation({
|
||||||
@@ -223,11 +224,11 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
|
|||||||
|
|
||||||
<div className="flex justify-between mt-4">
|
<div className="flex justify-between mt-4">
|
||||||
<div>
|
<div>
|
||||||
{defaultValues && (
|
{kind == "storage" && (
|
||||||
<ChannelTestButton
|
<ChannelTestButton
|
||||||
kind={kind}
|
kind={kind}
|
||||||
organizationId={organization?.id}
|
organizationId={organization?.id}
|
||||||
channel={defaultValues}
|
channel={form.getValues()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+12
-8
@@ -1,18 +1,21 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import {Button} from "@/components/ui/button";
|
|
||||||
import {NotificationChannel} from "@/db/schema/09_notification-channel";
|
|
||||||
import {useMutation} from "@tanstack/react-query";
|
import {useMutation} from "@tanstack/react-query";
|
||||||
import {dispatchNotification} from "@/features/notifications/dispatch";
|
|
||||||
import {EventPayload} from "@/features/notifications/types";
|
|
||||||
import {Send, ShieldCheck} from "lucide-react";
|
import {Send, ShieldCheck} from "lucide-react";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
|
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {NotificationChannel} from "@/db/schema/09_notification-channel";
|
||||||
|
import {dispatchNotification} from "@/features/notifications/dispatch";
|
||||||
|
import {EventPayload} from "@/features/notifications/types";
|
||||||
import {useIsMobile} from "@/hooks/use-mobile";
|
import {useIsMobile} from "@/hooks/use-mobile";
|
||||||
import {cn} from "@/lib/utils";
|
import {cn} from "@/lib/utils";
|
||||||
import {StorageChannel} from "@/db/schema/12_storage-channel";
|
import {StorageChannel} from "@/db/schema/12_storage-channel";
|
||||||
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
import {ChannelKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||||
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";
|
||||||
|
|
||||||
|
|
||||||
type NotifierTestChannelButtonProps = {
|
type NotifierTestChannelButtonProps = {
|
||||||
channel: NotificationChannel | StorageChannel;
|
channel: NotificationChannel | StorageChannel;
|
||||||
organizationId?: string;
|
organizationId?: string;
|
||||||
@@ -20,8 +23,9 @@ type NotifierTestChannelButtonProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestChannelButtonProps) => {
|
export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestChannelButtonProps) => {
|
||||||
const channelText = getChannelTextBasedOnKind(kind)
|
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (kind === "notification") {
|
if (kind === "notification") {
|
||||||
@@ -30,7 +34,7 @@ export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestC
|
|||||||
message: `We are testing channel ${channel.name}`,
|
message: `We are testing channel ${channel.name}`,
|
||||||
level: 'info',
|
level: 'info',
|
||||||
};
|
};
|
||||||
const result = await dispatchNotification(payload, undefined, channel.id, organizationId);
|
const result = await dispatchNotification(payload, undefined, channel, organizationId);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
@@ -41,7 +45,7 @@ export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestC
|
|||||||
const input: StorageInput = {
|
const input: StorageInput = {
|
||||||
action: "ping",
|
action: "ping",
|
||||||
};
|
};
|
||||||
const result = await dispatchStorage(input, undefined, channel.id);
|
const result = await dispatchStorage(input, undefined, undefined, channel);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success("Successfully connected to storage channel");
|
toast.success("Successfully connected to storage channel");
|
||||||
|
|||||||
+2
-2
@@ -47,7 +47,7 @@ export const NotifierSmtpForm = ({form}: NotifierSmtpFormProps) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Username</FormLabel>
|
<FormLabel>Username</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="contact@exemple.com" {...field} value={field.value ?? ""}/>
|
<Input placeholder="contact@example.com" {...field} value={field.value ?? ""}/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -77,7 +77,7 @@ export const NotifierSmtpForm = ({form}: NotifierSmtpFormProps) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>From Email</FormLabel>
|
<FormLabel>From Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder={`"Portabase" <exemple@portabase.io>`} {...field}
|
<Input placeholder={`"Portabase" <example@portabase.io>`} {...field}
|
||||||
value={field.value ?? ""}/>
|
value={field.value ?? ""}/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage/>
|
||||||
|
|||||||
+1
-1
@@ -136,7 +136,7 @@ export const StorageGoogleDriveForm = ({form}: StorageGoogleDriveFormProps) => {
|
|||||||
<input type="hidden" {...form.register("config.refreshToken")} />
|
<input type="hidden" {...form.register("config.refreshToken")} />
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button type="button" onClick={handleConnect}>
|
<Button type="button" variant={"secondary"} className="hover:cursor-pointer" onClick={handleConnect}>
|
||||||
{isConnected ? "Reconnect Google Drive" : "Connect Google Drive"}
|
{isConnected ? "Reconnect Google Drive" : "Connect Google Drive"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
+16
-3
@@ -19,9 +19,22 @@ export const StorageS3Form = ({form}: StorageS3FormProps) => {
|
|||||||
name="config.endPointUrl"
|
name="config.endPointUrl"
|
||||||
render={({field}) => (
|
render={({field}) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Endpoind URL</FormLabel>
|
<FormLabel>Endpoint URL</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="s3.exemple.com"/>
|
<Input {...field} placeholder="s3.amazonaws.com"/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage/>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="config.region"
|
||||||
|
render={({field}) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Region</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="us-east-1"/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -82,7 +95,7 @@ export const StorageS3Form = ({form}: StorageS3FormProps) => {
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.useSSL"
|
name="config.ssl"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Use SSL</FormLabel>
|
<FormLabel>Use SSL</FormLabel>
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export const EmailForm = (props: EmailFormProps) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>From Email *</FormLabel>
|
<FormLabel>From Email *</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder={"exemple@portabase.com"} {...field} />
|
<Input placeholder={"example@portabase.com"} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>{"The email from where the email will be send"}</FormDescription>
|
<FormDescription>{"The email from where the email will be send"}</FormDescription>
|
||||||
<FormMessage/>
|
<FormMessage/>
|
||||||
@@ -172,7 +172,7 @@ export const EmailForm = (props: EmailFormProps) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>User Email *</FormLabel>
|
<FormLabel>User Email *</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder={"exemple@portabase.com"} {...field} />
|
<Input placeholder={"example@portabase.com"} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>{"The email server user"}</FormDescription>
|
<FormDescription>{"The email server user"}</FormDescription>
|
||||||
<FormMessage/>
|
<FormMessage/>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const SettingsTabs = ({settings, storageChannels}: SettingsTabsProps) =>
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Default Storage',
|
name: 'Storage',
|
||||||
value: 'storage',
|
value: 'storage',
|
||||||
icon: Save,
|
icon: Save,
|
||||||
content: (
|
content: (
|
||||||
|
|||||||
+28
-5
@@ -4,7 +4,7 @@ import {Info} from "lucide-react";
|
|||||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||||
import {useRouter} from "next/navigation";
|
import {useRouter} from "next/navigation";
|
||||||
import {Setting} from "@/db/schema/01_setting";
|
import {Setting} from "@/db/schema/01_setting";
|
||||||
import {Form, FormField, FormItem, useZodForm} from "@/components/ui/form";
|
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
|
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
|
||||||
import {useMutation} from "@tanstack/react-query";
|
import {useMutation} from "@tanstack/react-query";
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
updateStorageSettingsAction
|
updateStorageSettingsAction
|
||||||
} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.action";
|
} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.action";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
|
import {Switch} from "@/components/ui/switch";
|
||||||
|
|
||||||
export type SettingsStorageSectionProps = {
|
export type SettingsStorageSectionProps = {
|
||||||
settings: Setting;
|
settings: Setting;
|
||||||
@@ -29,7 +30,8 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
|
|||||||
const form = useZodForm({
|
const form = useZodForm({
|
||||||
schema: DefaultStorageSchema,
|
schema: DefaultStorageSchema,
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
storageChannelId: settings.defaultStorageChannelId ?? undefined
|
storageChannelId: settings.defaultStorageChannelId ?? undefined,
|
||||||
|
encryption: settings.encryption ?? false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,6 +61,7 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
|
|||||||
</Alert>
|
</Alert>
|
||||||
<div className="flex flex-col h-full py-4 ">
|
<div className="flex flex-col h-full py-4 ">
|
||||||
<Form
|
<Form
|
||||||
|
className="space-y-4"
|
||||||
form={form}
|
form={form}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
await mutation.mutateAsync(values);
|
await mutation.mutateAsync(values);
|
||||||
@@ -70,6 +73,7 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
|
|||||||
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 min-w-[200px] sm:flex-grow-0 sm:w-64">
|
||||||
|
<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">
|
||||||
<SelectValue placeholder="Select a default storage channel"/>
|
<SelectValue placeholder="Select a default storage channel"/>
|
||||||
@@ -80,7 +84,8 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{getChannelIcon(channel.provider)}
|
{getChannelIcon(channel.provider)}
|
||||||
<span className="font-medium">{channel.name}</span>
|
<span className="font-medium">{channel.name}</span>
|
||||||
<span className="text-[9px] uppercase bg-secondary px-1.5 py-0.5 rounded">
|
<span
|
||||||
|
className="text-[9px] uppercase bg-secondary px-1.5 py-0.5 rounded">
|
||||||
{channel.provider}
|
{channel.provider}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,11 +96,29 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="encryption"
|
||||||
|
render={({field}) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Backups Encryption</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage/>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ButtonWithLoading className="flex-shrink-0 w-full sm:w-auto" type="submit">
|
<ButtonWithLoading className="flex-shrink-0 w-full sm:w-auto" type="submit">
|
||||||
Confirm
|
Confirm
|
||||||
</ButtonWithLoading>
|
</ButtonWithLoading>
|
||||||
</div>
|
|
||||||
|
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ export const updateStorageSettingsAction = userAction
|
|||||||
const {name, data} = parsedInput;
|
const {name, data} = parsedInput;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const [updatedSettings] = await db
|
const [updatedSettings] = await db
|
||||||
.update(drizzleDb.schemas.setting)
|
.update(drizzleDb.schemas.setting)
|
||||||
.set({
|
.set({
|
||||||
defaultStorageChannelId: data.storageChannelId,
|
defaultStorageChannelId: data.storageChannelId,
|
||||||
|
encryption: data.encryption,
|
||||||
})
|
})
|
||||||
.where(eq(drizzleDb.schemas.setting.name, name))
|
.where(eq(drizzleDb.schemas.setting.name, name))
|
||||||
.returning();
|
.returning();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
export const DefaultStorageSchema = z.object({
|
export const DefaultStorageSchema = z.object({
|
||||||
storageChannelId: z.string(),
|
storageChannelId: z.string(),
|
||||||
|
encryption: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DefaultStorageType = z.infer<typeof DefaultStorageSchema>;
|
export type DefaultStorageType = z.infer<typeof DefaultStorageSchema>;
|
||||||
|
|||||||
@@ -1,23 +1,145 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import {PasswordInput} from "@/components/ui/password-input";
|
|
||||||
|
import {Input} from "@/components/ui/input";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {Copy, Check, Eye, EyeOff, Terminal, Key, Info} from "lucide-react";
|
||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import {CopyButton} from "@/components/wrappers/common/button/copy-button";
|
import {copyToClipboardWithMeta} from "@/components/wrappers/common/button/copy-button";
|
||||||
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
|
||||||
|
|
||||||
export type AgentCardKeyProps = {
|
export type AgentCardKeyProps = {
|
||||||
edgeKey: string;
|
edgeKey: string;
|
||||||
|
agentName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentCardKey = ({edgeKey, agentName}: AgentCardKeyProps) => {
|
||||||
|
const [isCopiedKey, setIsCopiedKey] = useState(false);
|
||||||
|
const [isCopiedCommand, setIsCopiedCommand] = useState(false);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
const command = `portabase agent "${agentName}" --key ${edgeKey}`;
|
||||||
|
const maskedKey = "••••••••••••••••••••••••••••••••";
|
||||||
|
|
||||||
|
const handleCopy = async (text: string, setter: (v: boolean) => void) => {
|
||||||
|
await copyToClipboardWithMeta(text);
|
||||||
|
setter(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AgentCardKey = ({edgeKey}: AgentCardKeyProps) => {
|
|
||||||
const [code, setCode] = useState<string>(`${edgeKey}`);
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4 py-2">
|
||||||
<PasswordInput
|
<Card className="border-muted/60 shadow-none py-0">
|
||||||
value={code}
|
<CardHeader className="px-4 pt-4">
|
||||||
onChange={() => {
|
<CardTitle className="text-sm font-semibold flex items-center gap-2 uppercase tracking-tight">
|
||||||
setCode(edgeKey);
|
CLI Setup
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs leading-relaxed">
|
||||||
|
To setup your agent using the CLI, copy the command below and paste it in your terminal.
|
||||||
|
Make sure you have the PortaBase CLI installed. If not, you can <a href="https://portabase.io/docs/cli" target="_blank" className="underline hover:text-primary">install it here</a>.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4 space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
value={isVisible ? command : `portabase agent "${agentName}" --key ${maskedKey}`}
|
||||||
|
onFocus={(e) => {
|
||||||
|
setIsVisible(true);
|
||||||
|
handleCopy(command, setIsCopiedCommand);
|
||||||
|
e.currentTarget.select();
|
||||||
}}
|
}}
|
||||||
|
onClick={(e) => e.currentTarget.select()}
|
||||||
|
onBlur={() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
setIsCopiedCommand(false);
|
||||||
|
}}
|
||||||
|
className="font-mono text-xs bg-muted/30 h-10 pr-10 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<CopyButton className="mt-5" value={code}/>
|
<Button
|
||||||
</>
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsVisible(!isVisible);
|
||||||
|
}}
|
||||||
|
className="absolute right-1 top-1.5 h-7 w-7"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isVisible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleCopy(command, setIsCopiedCommand)}
|
||||||
|
className="shrink-0 gap-2 h-10 px-4"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isCopiedCommand ? (
|
||||||
|
<><Check className="h-4 w-4 text-green-500" /></>
|
||||||
|
) : (
|
||||||
|
<><Copy className="h-4 w-4" /></>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-muted/60 shadow-none py-0">
|
||||||
|
<CardHeader className="px-4 pt-4">
|
||||||
|
<CardTitle className="text-sm font-semibold flex items-center gap-2 uppercase tracking-tight">
|
||||||
|
Manual Setup
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs leading-relaxed">
|
||||||
|
Use this key to manually configure your agent in your configuration file.
|
||||||
|
If you need help for manual configuration, you can follow our guide in the <a href="https://portabase.io/docs" target="_blank" className="underline hover:text-primary">documentation</a>.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4 space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
value={isVisible ? edgeKey : maskedKey}
|
||||||
|
onFocus={(e) => {
|
||||||
|
setIsVisible(true);
|
||||||
|
handleCopy(edgeKey, setIsCopiedKey);
|
||||||
|
e.currentTarget.select();
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.currentTarget.select()}
|
||||||
|
onBlur={() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
setIsCopiedKey(false);
|
||||||
|
}}
|
||||||
|
className="font-mono text-xs bg-muted/30 h-10 pr-10 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsVisible(!isVisible);
|
||||||
|
}}
|
||||||
|
className="absolute right-1 top-1.5 h-7 w-7 hover:bg-transparent"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isVisible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleCopy(edgeKey, setIsCopiedKey)}
|
||||||
|
className="shrink-0 gap-2 h-10 px-4 "
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isCopiedKey ? (
|
||||||
|
<><Check className="h-4 w-4 text-green-500" /></>
|
||||||
|
) : (
|
||||||
|
<><Copy className="h-4 w-4" /></>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import {Card} from "@/components/ui/card";
|
|||||||
import {ConnectionIndicator} from "@/components/wrappers/common/connection-indicator";
|
import {ConnectionIndicator} from "@/components/wrappers/common/connection-indicator";
|
||||||
import {formatDateLastContact} from "@/utils/date-formatting";
|
import {formatDateLastContact} from "@/utils/date-formatting";
|
||||||
import {AgentWith} from "@/db/schema/08_agent";
|
import {AgentWith} from "@/db/schema/08_agent";
|
||||||
import {Activity, ChevronRight, Copy, Check, Fingerprint, Server, Database} from "lucide-react";
|
import {Activity, ChevronRight, Copy, Check, Fingerprint, Server, Database, AlertCircle} from "lucide-react";
|
||||||
import {Badge} from "@/components/ui/badge";
|
import {Badge} from "@/components/ui/badge";
|
||||||
import {truncateWords} from "@/utils/text";
|
import {truncateWords} from "@/utils/text";
|
||||||
import {useIsMobile} from "@/hooks/use-mobile";
|
import {useIsMobile} from "@/hooks/use-mobile";
|
||||||
|
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip";
|
||||||
|
import {useAgentUpdateCheck} from "@/features/agents/hooks/use-agent-update-check";
|
||||||
|
|
||||||
export type agentCardProps = {
|
export type agentCardProps = {
|
||||||
data: AgentWith;
|
data: AgentWith;
|
||||||
@@ -20,6 +22,8 @@ export const AgentCard = (props: agentCardProps) => {
|
|||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [isCopied, setIsCopied] = useState(false);
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
|
|
||||||
|
const {newRelease, isUpdateAvailable} = useAgentUpdateCheck(agent.version);
|
||||||
|
|
||||||
const handleCopy = (e: React.MouseEvent) => {
|
const handleCopy = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -44,9 +48,23 @@ export const AgentCard = (props: agentCardProps) => {
|
|||||||
{isMobile ? truncateWords(agent.name, 2) : agent.name}
|
{isMobile ? truncateWords(agent.name, 2) : agent.name}
|
||||||
</h3>
|
</h3>
|
||||||
{agent.version && (
|
{agent.version && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<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>
|
||||||
|
{isUpdateAvailable && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex items-center justify-center w-5 h-5 bg-yellow-500/10 rounded-full border border-yellow-500/20 text-yellow-600">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Update available: {newRelease?.tag_name}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,76 +4,111 @@ import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
|
|||||||
import {Server} from "lucide-react";
|
import {Server} from "lucide-react";
|
||||||
import {formatDateLastContact} from "@/utils/date-formatting";
|
import {formatDateLastContact} from "@/utils/date-formatting";
|
||||||
import {AgentCardKey} from "@/components/wrappers/dashboard/agent/agent-card-key/agent-card-key";
|
import {AgentCardKey} from "@/components/wrappers/dashboard/agent/agent-card-key/agent-card-key";
|
||||||
|
import {AgentWithDatabases} from "@/db/schema/08_agent";
|
||||||
|
import {useQuery} from "@tanstack/react-query";
|
||||||
|
import {getAgentAction} from "@/features/agents/agents.action";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import {Separator} from "@/components/ui/separator";
|
||||||
|
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 {AgentWithDatabases} from "@/db/schema/08_agent";
|
|
||||||
import {eventUpdate} from "@/types/events";
|
|
||||||
import {useAutoRefresh} from "@/hooks/use-auto-refresh";
|
|
||||||
|
|
||||||
type AgentContentPageProps = {
|
type AgentContentPageProps = {
|
||||||
edgeKey: string;
|
edgeKey: string;
|
||||||
agent: AgentWithDatabases
|
agent: AgentWithDatabases
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AgentContentPage = ({edgeKey, agent}: AgentContentPageProps) => {
|
export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPageProps) => {
|
||||||
|
|
||||||
useAutoRefresh({
|
const {data} = useQuery({
|
||||||
poll: {
|
queryKey: ["agent-data", initialAgent.id],
|
||||||
enabled: true,
|
queryFn: async () => {
|
||||||
intervalMs: 5000,
|
const result = await getAgentAction(initialAgent.id);
|
||||||
},
|
return result?.data;
|
||||||
sse: {
|
|
||||||
enabled: true,
|
|
||||||
url: "/api/events",
|
|
||||||
eventName: "modification",
|
|
||||||
shouldRefresh: (data) => {
|
|
||||||
const update = data as eventUpdate;
|
|
||||||
return Boolean(update?.update);
|
|
||||||
},
|
},
|
||||||
|
initialData: {
|
||||||
|
data: initialAgent
|
||||||
},
|
},
|
||||||
|
staleTime: 0,
|
||||||
|
gcTime: 0,
|
||||||
|
refetchInterval: 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const agent = data?.data ?? initialAgent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<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">
|
<Card className="w-full sm:w-auto flex-1 border-none shadow-none bg-muted/30">
|
||||||
<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-medium">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"/>
|
<Server className="h-4 w-4 text-muted-foreground opacity-50"/>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{agent.databases.length}</div>
|
<div className="text-3xl font-bold tracking-tight">{agent.databases.length}</div>
|
||||||
<p className="text-xs text-muted-foreground">Databases linked to this agent</p>
|
<p className="text-xs text-muted-foreground mt-1">Linked resources</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="w-full sm:w-auto flex-1">
|
<Card className="w-full sm:w-auto flex-1 border-none shadow-none bg-muted/30">
|
||||||
<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-medium">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"/>
|
<Server className="h-4 w-4 text-muted-foreground opacity-50"/>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{formatDateLastContact(agent.lastContact)}</div>
|
<div className="text-3xl font-bold tracking-tight">{formatDateLastContact(agent.lastContact)}</div>
|
||||||
<p className="text-xs text-muted-foreground">Last contact with agent</p>
|
<p className="text-xs text-muted-foreground mt-1">Status heartbeat</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<Card className="w-full sm:w-auto flex-1 ">
|
|
||||||
<CardHeader className="font-bold text-xl">
|
<div className="space-y-6">
|
||||||
Edge Key
|
<Accordion type="single" collapsible defaultValue={!agent.lastContact ? "registration" : undefined}>
|
||||||
</CardHeader>
|
<AccordionItem value="registration" className="border rounded-xl px-6 bg-card shadow-sm overflow-hidden transition-all duration-300">
|
||||||
<CardContent>
|
<AccordionTrigger className="hover:no-underline py-4 group">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl font-bold tracking-tight">Registration & Setup</span>
|
||||||
|
{!agent.lastContact && (
|
||||||
|
<Badge variant="outline" className="bg-orange-500/10 text-orange-600 border-orange-500/20 animate-pulse">
|
||||||
|
Action Required
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="pb-6 pt-2 border-t border-dashed">
|
||||||
<AgentCardKey
|
<AgentCardKey
|
||||||
edgeKey={edgeKey}
|
edgeKey={edgeKey}
|
||||||
|
agentName={agent.name}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</AccordionContent>
|
||||||
</Card>
|
</AccordionItem>
|
||||||
<CardsWithPagination cardsPerPage={4} numberOfColumns={2} data={agent.databases}
|
</Accordion>
|
||||||
cardItem={AgentDatabaseCard}/>
|
</div>
|
||||||
|
|
||||||
</>
|
{agent.databases.length > 0 && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Managed Databases</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Resources currently connected to this agent.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="opacity-50" />
|
||||||
|
<CardsWithPagination
|
||||||
|
cardsPerPage={4}
|
||||||
|
numberOfColumns={2}
|
||||||
|
data={agent.databases}
|
||||||
|
cardItem={AgentDatabaseCard}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {ColumnDef} from "@tanstack/react-table"
|
||||||
|
import {Database} from "@/db/schema/07_database"
|
||||||
|
import Image from "next/image"
|
||||||
|
import {ConnectionIndicator} from "@/components/wrappers/common/connection-indicator"
|
||||||
|
import {formatDateLastContact} from "@/utils/date-formatting"
|
||||||
|
import Link from "next/link"
|
||||||
|
import {Button} from "@/components/ui/button"
|
||||||
|
import {ChevronRight} from "lucide-react"
|
||||||
|
|
||||||
|
export const agentDatabaseColumns: ColumnDef<Database>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "dbms",
|
||||||
|
header: "Type",
|
||||||
|
cell: ({row}) => (
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 p-1.5 bg-muted/50 rounded-lg border border-border/50">
|
||||||
|
<Image
|
||||||
|
src={`/images/${row.original.dbms}.png`}
|
||||||
|
alt={row.original.dbms}
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: "Name",
|
||||||
|
cell: ({row}) => (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-bold text-sm">{row.original.name}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[150px]">
|
||||||
|
{row.original.agentDatabaseId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "lastContact",
|
||||||
|
header: "Last Contact",
|
||||||
|
cell: ({row}) => (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs">
|
||||||
|
{formatDateLastContact(row.original.lastContact)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: ({row}) => (
|
||||||
|
<div className="flex justify-center w-full">
|
||||||
|
<ConnectionIndicator date={row.original.lastContact} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => null,
|
||||||
|
cell: ({row}) => {
|
||||||
|
const href = row.original.projectId
|
||||||
|
? `/dashboard/projects/${row.original.projectId}/database/${row.original.id}`
|
||||||
|
: "#";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="ghost" size="icon" asChild className="hover:bg-accent group">
|
||||||
|
<Link href={href}>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, DialogClose } from "@/components/ui/dialog";
|
||||||
|
|
||||||
import { generateEdgeKey } from "@/utils/edge_key";
|
import { generateEdgeKey } from "@/utils/edge_key";
|
||||||
import { PropsWithChildren } from "react";
|
import { PropsWithChildren } from "react";
|
||||||
@@ -16,26 +16,29 @@ export type agentRegistrationDialogProps = PropsWithChildren<{
|
|||||||
|
|
||||||
export function AgentModalKey(props: agentRegistrationDialogProps) {
|
export function AgentModalKey(props: agentRegistrationDialogProps) {
|
||||||
const edge_key = generateEdgeKey(getServerUrl(), props.agent.id);
|
const edge_key = generateEdgeKey(getServerUrl(), props.agent.id);
|
||||||
const code = `EDGE_KEY = ${edge_key}`;
|
const command = `portabase agent "${props.agent.name}" --key ${edge_key}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>{props.children}</DialogTrigger>
|
<DialogTrigger asChild>{props.children}</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[425px] w-full">
|
<DialogContent className="sm:max-w-[425px] w-full">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Agent Edge Key</DialogTitle>
|
<DialogTitle>Agent Connection</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="sm:max-w-[375px] w-full">
|
<div className="sm:max-w-[375px] w-full space-y-4">
|
||||||
<CodeSnippet
|
<CodeSnippet
|
||||||
code={code}
|
title="Installation Command"
|
||||||
// className="w-full overflow-x-auto break-words"
|
code={command}
|
||||||
|
/>
|
||||||
|
<CodeSnippet
|
||||||
|
title="Agent Key (Manual)"
|
||||||
|
code={edge_key}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<div className="flex items-center justify-between w-full">
|
<DialogClose asChild>
|
||||||
<CopyButton value={code} />
|
<Button type="button" variant="outline">Close</Button>
|
||||||
<Button type="submit">Save changes</Button>
|
</DialogClose>
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
||||||
import {useRouter} from "next/navigation";
|
import {useRouter} from "next/navigation";
|
||||||
|
|
||||||
import {useMutation} from "@tanstack/react-query";
|
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
|
|
||||||
import {backupButtonAction} from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
|
import {backupButtonAction} from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
|
||||||
@@ -16,6 +15,7 @@ export type BackupButtonProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const BackupButton = (props: BackupButtonProps) => {
|
export const BackupButton = (props: BackupButtonProps) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ export const BackupButton = (props: BackupButtonProps) => {
|
|||||||
const backup = await backupButtonAction(databaseId);
|
const backup = await backupButtonAction(databaseId);
|
||||||
if (backup?.data?.success) {
|
if (backup?.data?.success) {
|
||||||
toast.success(backup.data.actionSuccess?.message || "Backup created successfully!");
|
toast.success(backup.data.actionSuccess?.message || "Backup created successfully!");
|
||||||
|
queryClient.invalidateQueries({queryKey: ["database-data", props.databaseId]});
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast.error(backup?.serverError || "Failed to create backup.");
|
toast.error(backup?.serverError || "Failed to create backup.");
|
||||||
|
|||||||
@@ -1,26 +1,36 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {PropsWithChildren, ReactNode, useState} from "react";
|
import {PropsWithChildren, ReactNode, useState} from "react";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { CircleUser, LogOut } from "lucide-react";
|
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { LogOut, User } from "lucide-react";
|
||||||
|
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
import { signOut } from "@/lib/auth/auth-client";
|
import { signOut } from "@/lib/auth/auth-client";
|
||||||
|
|
||||||
import {ProfileModal} from "@/components/wrappers/dashboard/common/profile/profile-modal";
|
import {ProfileModal} from "@/components/wrappers/dashboard/common/profile/profile-modal";
|
||||||
import {Account, Session, User} from "@/db/schema/02_user";
|
|
||||||
|
import {Account, Session, User as UserType} from "@/db/schema/02_user";
|
||||||
|
|
||||||
import {AuthProviderConfig} from "../../../../../../portabase.config";
|
import {AuthProviderConfig} from "../../../../../../portabase.config";
|
||||||
|
|
||||||
export type LoggedInDropdownProps = PropsWithChildren<{
|
export type LoggedInDropdownProps = PropsWithChildren<{
|
||||||
user: User;
|
user: UserType;
|
||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
currentSession: Session;
|
currentSession: Session;
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
providers: AuthProviderConfig[]
|
providers: AuthProviderConfig[]
|
||||||
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers }: LoggedInDropdownProps) => {
|
export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers }: LoggedInDropdownProps) => {
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -34,17 +44,27 @@ export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, chi
|
|||||||
onOpenChange={setIsModalOpen}
|
onOpenChange={setIsModalOpen}
|
||||||
providers={providers}
|
providers={providers}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent side="top" className="min-w-[var(--radix-popper-anchor-width)]">
|
<DropdownMenuContent
|
||||||
<DropdownMenuItem onClick={() => setIsModalOpen(!isModalOpen)}>
|
className="w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none p-1"
|
||||||
<div className="flex justify-start items-center gap-2">
|
align="start"
|
||||||
<CircleUser size={16} />
|
side="top"
|
||||||
<span>Account</span>
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setIsModalOpen(!isModalOpen)}
|
||||||
|
className="group gap-2 p-1 cursor-pointer rounded-lg mb-1 transition-colors focus:bg-accent hover:bg-accent/50 border border-transparent"
|
||||||
|
>
|
||||||
|
<div className="flex size-9 items-center justify-center rounded-md border border-border bg-muted/50 shadow-sm transition-all group-hover:shadow-md group-hover:bg-background">
|
||||||
|
<User size={18} className="text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium leading-none">Account Settings</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
className="group gap-2 p-1 cursor-pointer rounded-lg transition-colors focus:bg-red-50 dark:focus:bg-red-950/20 border border-transparent text-red-600 focus:text-red-600"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await signOut({
|
await signOut({
|
||||||
fetchOptions: {
|
fetchOptions: {
|
||||||
@@ -55,13 +75,19 @@ export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, chi
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex justify-start items-center gap-2">
|
<div className="flex size-9 items-center justify-center rounded-md border border-red-100 bg-red-50/50 dark:border-red-900/30 dark:bg-red-950/20 shadow-sm transition-all group-hover:shadow-md">
|
||||||
<LogOut size={16} className="text-red-500" />
|
<LogOut size={18} className="text-red-500" />
|
||||||
<span className="text-red-500">Logout</span>
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium leading-none">Logout</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</>
|
</>
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,20 +7,17 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
ChartArea,
|
ChartArea,
|
||||||
ShieldHalf,
|
ShieldHalf,
|
||||||
Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse
|
Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse, BookOpen
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {SidebarGroupItem, SidebarMenuCustomBase} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar";
|
import {SidebarGroupItem, SidebarMenuCustomBase} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar";
|
||||||
import {authClient, useSession} from "@/lib/auth/auth-client";
|
import {authClient} from "@/lib/auth/auth-client";
|
||||||
|
|
||||||
|
|
||||||
export const SidebarMenuCustomMain = () => {
|
export const SidebarMenuCustomMain = () => {
|
||||||
|
|
||||||
const BASE_URL = `/dashboard`;
|
const BASE_URL = `/dashboard`;
|
||||||
|
|
||||||
const {data: activeOrganization} = authClient.useActiveOrganization();
|
|
||||||
const {data: organizations} = authClient.useListOrganizations();
|
|
||||||
const {data: session, isPending, error} = authClient.useSession();
|
const {data: session, isPending, error} = authClient.useSession();
|
||||||
const member = authClient.useActiveMember();
|
|
||||||
|
|
||||||
if (isPending) return null;
|
if (isPending) return null;
|
||||||
|
|
||||||
@@ -107,6 +104,23 @@ export const SidebarMenuCustomMain = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
items.push(
|
||||||
|
{
|
||||||
|
label: "Resources",
|
||||||
|
type: "list",
|
||||||
|
group_content: [
|
||||||
|
{
|
||||||
|
title: "Documentation",
|
||||||
|
url: "https://portabase.io/docs",
|
||||||
|
icon: BookOpen,
|
||||||
|
type: "item",
|
||||||
|
redirect: true,
|
||||||
|
not_from_base_url: true,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
return <SidebarMenuCustomBase baseUrl={BASE_URL} items={items}/>;
|
return <SidebarMenuCustomBase baseUrl={BASE_URL} items={items}/>;
|
||||||
};
|
};
|
||||||
@@ -33,7 +33,7 @@ export function DatabaseActionsCell({backup, activeMember, isAlreadyRestore}: Da
|
|||||||
<div className={cn("flex items-center space-x-2")}>
|
<div className={cn("flex items-center space-x-2")}>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon" type="button" onClick={(e) => e.stopPropagation()}>
|
||||||
<span className="sr-only">Open menu</span>
|
<span className="sr-only">Open menu</span>
|
||||||
<MoreHorizontal className="w-4 h-4"/>
|
<MoreHorizontal className="w-4 h-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import {Backup, BackupWith, Restoration} from "@/db/schema/07_database";
|
import {Backup, BackupWith, Restoration} from "@/db/schema/07_database";
|
||||||
import React from "react";
|
|
||||||
import {Swiper, SwiperSlide} from "swiper/react";
|
import {Swiper, SwiperSlide} from "swiper/react";
|
||||||
|
//@ts-ignore
|
||||||
import "swiper/css";
|
import "swiper/css";
|
||||||
import "swiper/css/pagination";
|
import "swiper/css/pagination";
|
||||||
import {Pagination, Mousewheel} from "swiper/modules";
|
import {Pagination, Mousewheel} from "swiper/modules";
|
||||||
@@ -12,14 +12,13 @@ import {
|
|||||||
BackupActionsType
|
BackupActionsType
|
||||||
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.schema";
|
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.schema";
|
||||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||||
import {useMutation} from "@tanstack/react-query";
|
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
||||||
import {BackupStorageWith} from "@/db/schema/14_storage-backup";
|
import {BackupStorageWith} from "@/db/schema/14_storage-backup";
|
||||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||||
import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||||
import {truncateWords} from "@/utils/text";
|
|
||||||
import {useIsMobile} from "@/hooks/use-mobile";
|
|
||||||
import {Badge} from "@/components/ui/badge";
|
import {Badge} from "@/components/ui/badge";
|
||||||
import {getStatusColor, getStatusIcon} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
|
import {getStatusColor, getStatusIcon} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
|
||||||
|
import {useRouter} from "next/navigation";
|
||||||
import {
|
import {
|
||||||
createRestorationBackupAction, deleteBackupAction, deleteBackupStorageAction,
|
createRestorationBackupAction, deleteBackupAction, deleteBackupStorageAction,
|
||||||
downloadBackupAction
|
downloadBackupAction
|
||||||
@@ -29,8 +28,7 @@ import {SafeActionResult} from "next-safe-action";
|
|||||||
import {ServerActionResult} from "@/types/action-type";
|
import {ServerActionResult} from "@/types/action-type";
|
||||||
import {ZodString} from "zod";
|
import {ZodString} from "zod";
|
||||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
|
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
|
||||||
import {AlertCircleIcon, Trash2} from "lucide-react";
|
import {AlertCircleIcon} from "lucide-react";
|
||||||
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
|
||||||
|
|
||||||
type BackupActionsFormProps = {
|
type BackupActionsFormProps = {
|
||||||
backup: BackupWith;
|
backup: BackupWith;
|
||||||
@@ -40,8 +38,9 @@ type BackupActionsFormProps = {
|
|||||||
export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
|
export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
|
||||||
|
|
||||||
const filteredBackupStorages = backup.storages?.filter((storage) => storage.deletedAt === null) ?? []
|
const filteredBackupStorages = backup.storages?.filter((storage) => storage.deletedAt === null) ?? []
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const {closeModal} = useBackupModal();
|
const {closeModal} = useBackupModal();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const form = useZodForm({
|
const form = useZodForm({
|
||||||
schema: BackupActionsSchema,
|
schema: BackupActionsSchema,
|
||||||
@@ -74,8 +73,9 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
|
|||||||
|
|
||||||
if (inner?.success) {
|
if (inner?.success) {
|
||||||
toast.success(inner.actionSuccess?.message);
|
toast.success(inner.actionSuccess?.message);
|
||||||
|
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
|
||||||
|
router.refresh();
|
||||||
if (action === "download") {
|
if (action === "download") {
|
||||||
|
|
||||||
const url = inner.value
|
const url = inner.value
|
||||||
if (typeof url === "string") {
|
if (typeof url === "string") {
|
||||||
window.open(url, "_self");
|
window.open(url, "_self");
|
||||||
@@ -91,6 +91,8 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
|
|||||||
} else {
|
} else {
|
||||||
if (action === "delete") {
|
if (action === "delete") {
|
||||||
toast.success("Backup deleted successfully.")
|
toast.success("Backup deleted successfully.")
|
||||||
|
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
|
||||||
|
router.refresh();
|
||||||
closeModal()
|
closeModal()
|
||||||
} else {
|
} else {
|
||||||
toast.error(inner?.actionError?.message ?? "An error occurred.");
|
toast.error(inner?.actionError?.message ?? "An error occurred.");
|
||||||
@@ -111,6 +113,8 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
|
|||||||
|
|
||||||
if (inner?.success) {
|
if (inner?.success) {
|
||||||
toast.success(inner.actionSuccess?.message);
|
toast.success(inner.actionSuccess?.message);
|
||||||
|
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
|
||||||
|
router.refresh();
|
||||||
closeModal()
|
closeModal()
|
||||||
} else {
|
} else {
|
||||||
toast.error(inner?.actionError?.message);
|
toast.error(inner?.actionError?.message);
|
||||||
@@ -130,10 +134,7 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
||||||
|
|
||||||
{filteredBackupStorages.length > 0 ?
|
{filteredBackupStorages.length > 0 ?
|
||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="backupStorageId"
|
name="backupStorageId"
|
||||||
@@ -176,19 +177,21 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className="shrink-0">
|
||||||
{getChannelIcon(storage.storageChannel?.provider || "")}
|
{getChannelIcon(storage.storageChannel?.provider || "")}
|
||||||
<h3 className="font-medium text-foreground">
|
</div>
|
||||||
{isMobile ? truncateWords(storage?.storageChannel?.name ?? "", 2) : storage.storageChannel?.name}
|
<h3 className="font-medium text-foreground truncate">
|
||||||
|
{storage.storageChannel?.name}
|
||||||
</h3>
|
</h3>
|
||||||
<Badge variant="secondary"
|
<Badge variant="secondary"
|
||||||
className="text-xs font-mono">
|
className="text-xs font-mono shrink-0">
|
||||||
{storage.storageChannel?.provider}
|
{storage.storageChannel?.provider}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline"
|
<Badge variant="outline"
|
||||||
className={`gap-1.5 ${getStatusColor(storage.status)}`}>
|
className={`gap-1.5 shrink-0 ${getStatusColor(storage.status)}`}>
|
||||||
{getStatusIcon(storage.status === "success")}
|
{getStatusIcon(storage.status === "success")}
|
||||||
<span
|
<span
|
||||||
className="capitalize">{storage.status.toUpperCase()}</span>
|
className="capitalize">{storage.status.toUpperCase()}</span>
|
||||||
@@ -221,45 +224,15 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
|
|||||||
|
|
||||||
<div className="flex flex-row items-center gap-x-4 w-full">
|
<div className="flex flex-row items-center gap-x-4 w-full">
|
||||||
{action === "delete" && (
|
{action === "delete" && (
|
||||||
// <ButtonWithLoading
|
<ButtonWithLoading
|
||||||
// type="button"
|
type="button"
|
||||||
// variant="destructive"
|
variant="destructive"
|
||||||
// onClick={() => mutationDeleteEntireBackup.mutateAsync()}
|
onClick={() => mutationDeleteEntireBackup.mutateAsync()}
|
||||||
// isPending={mutationDeleteEntireBackup.isPending}
|
|
||||||
// disabled={mutationDeleteEntireBackup.isPending}
|
|
||||||
// >
|
|
||||||
// Delete entire backup
|
|
||||||
// </ButtonWithLoading>
|
|
||||||
|
|
||||||
|
|
||||||
<ButtonWithConfirm
|
|
||||||
title={"Delete entire backup"}
|
|
||||||
description={"Are you sure you want to delete this entire backup?"}
|
|
||||||
button={{
|
|
||||||
main: {
|
|
||||||
type: "button",
|
|
||||||
variant: "destructive",
|
|
||||||
text: "Delete entire backup",
|
|
||||||
},
|
|
||||||
confirm: {
|
|
||||||
className: "w-full",
|
|
||||||
text: "Delete",
|
|
||||||
icon: <Trash2/>,
|
|
||||||
variant: "destructive",
|
|
||||||
onClick: async () => {
|
|
||||||
mutationDeleteEntireBackup.mutateAsync()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cancel: {
|
|
||||||
className: "w-full",
|
|
||||||
text: "Cancel",
|
|
||||||
icon: <Trash2/>,
|
|
||||||
variant: "outline",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
isPending={mutationDeleteEntireBackup.isPending}
|
isPending={mutationDeleteEntireBackup.isPending}
|
||||||
/>
|
disabled={mutationDeleteEntireBackup.isPending}
|
||||||
|
>
|
||||||
|
Delete entire backup
|
||||||
|
</ButtonWithLoading>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredBackupStorages.length > 0 && (
|
{filteredBackupStorages.length > 0 && (
|
||||||
|
|||||||
@@ -58,9 +58,7 @@ export const downloadBackupAction = userAction.schema(
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const result = await dispatchStorage(input, undefined, backupStorage.storageChannelId);
|
const result = await dispatchStorage(input, undefined, backupStorage.storageChannelId);
|
||||||
console.log(result);
|
|
||||||
return {
|
return {
|
||||||
success: result.success,
|
success: result.success,
|
||||||
value: result.url,
|
value: result.url,
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use server"
|
||||||
|
import {userAction} from "@/lib/safe-actions/actions";
|
||||||
|
import {z} from "zod";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import {eq} from "drizzle-orm";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {BackupWith, Restoration} from "@/db/schema/07_database";
|
||||||
|
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
||||||
|
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
||||||
|
|
||||||
|
export const getDatabaseDataAction = userAction
|
||||||
|
.schema(
|
||||||
|
z.object({
|
||||||
|
databaseId: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.action(async ({parsedInput}) => {
|
||||||
|
const {databaseId} = parsedInput;
|
||||||
|
|
||||||
|
const database = await db.query.database.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.database.id, databaseId),
|
||||||
|
with: {
|
||||||
|
project: true,
|
||||||
|
retentionPolicy: true,
|
||||||
|
alertPolicies: true,
|
||||||
|
storagePolicies: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const backups = await db.query.backup.findMany({
|
||||||
|
where: eq(drizzleDb.schemas.backup.databaseId, databaseId),
|
||||||
|
with: {
|
||||||
|
restorations: true,
|
||||||
|
storages: {
|
||||||
|
with: {
|
||||||
|
storageChannel: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: (b, {desc}) => [desc(b.createdAt)],
|
||||||
|
}) as BackupWith[];
|
||||||
|
|
||||||
|
const restorations = await db.query.restoration.findMany({
|
||||||
|
where: eq(drizzleDb.schemas.restoration.databaseId, databaseId),
|
||||||
|
orderBy: (r, {desc}) => [desc(r.createdAt)],
|
||||||
|
}) as Restoration[];
|
||||||
|
|
||||||
|
const totalBackups = backups.length;
|
||||||
|
const availableBackups = backups.filter(b => !b.deletedAt).length;
|
||||||
|
const successfulBackups = backups.filter(b => b.status === "success").length;
|
||||||
|
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
||||||
|
|
||||||
|
let activeOrganizationChannels = [];
|
||||||
|
let activeOrganizationStorageChannels = [];
|
||||||
|
|
||||||
|
if (database?.project?.organizationId) {
|
||||||
|
const organizationChannels = await getOrganizationChannels(database.project.organizationId);
|
||||||
|
activeOrganizationChannels = organizationChannels.filter(channel => channel.enabled);
|
||||||
|
|
||||||
|
const organizationStorageChannels = await getOrganizationStorageChannels(database.project.organizationId);
|
||||||
|
activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
database,
|
||||||
|
backups,
|
||||||
|
restorations,
|
||||||
|
activeOrganizationChannels,
|
||||||
|
activeOrganizationStorageChannels,
|
||||||
|
stats: {
|
||||||
|
totalBackups,
|
||||||
|
availableBackups,
|
||||||
|
successRate
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import {createContext, useContext, useState, ReactNode} from "react";
|
import {createContext, useContext, useState, ReactNode} from "react";
|
||||||
import {BackupWith} from "@/db/schema/07_database";
|
import {BackupWith} from "@/db/schema/07_database";
|
||||||
import {useRouter} from "next/navigation";
|
|
||||||
|
|
||||||
export type DatabaseActionKind = "restore" | "download" | "delete";
|
export type DatabaseActionKind = "restore" | "download" | "delete";
|
||||||
|
|
||||||
@@ -32,7 +31,6 @@ const BackupModalContext = createContext<BackupModalContextType | undefined>(und
|
|||||||
|
|
||||||
export const BackupModalProvider = ({children}: { children: ReactNode }) => {
|
export const BackupModalProvider = ({children}: { children: ReactNode }) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const router = useRouter();
|
|
||||||
const [action, setAction] = useState<DatabaseActionKind | null>(null);
|
const [action, setAction] = useState<DatabaseActionKind | null>(null);
|
||||||
const [backup, setBackup] = useState<BackupWith | null>(null);
|
const [backup, setBackup] = useState<BackupWith | null>(null);
|
||||||
|
|
||||||
@@ -46,7 +44,6 @@ export const BackupModalProvider = ({children}: { children: ReactNode }) => {
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
setAction(null);
|
setAction(null);
|
||||||
setBackup(null);
|
setBackup(null);
|
||||||
router.refresh()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import {Button} from "@/components/ui/button";
|
|||||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
|
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
|
||||||
import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select";
|
import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select";
|
||||||
import {useMutation} from "@tanstack/react-query";
|
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {useRouter} from "next/navigation";
|
|
||||||
import {Switch} from "@/components/ui/switch";
|
import {Switch} from "@/components/ui/switch";
|
||||||
import {Card} from "@/components/ui/card";
|
import {Card} from "@/components/ui/card";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useIsMobile} from "@/hooks/use-mobile";
|
import {useIsMobile} from "@/hooks/use-mobile";
|
||||||
|
import {useRouter} from "next/navigation";
|
||||||
import {
|
import {
|
||||||
ChannelKind,
|
ChannelKind,
|
||||||
getChannelIcon,
|
getChannelIcon,
|
||||||
@@ -48,6 +48,7 @@ export const ChannelPoliciesForm = ({
|
|||||||
onSuccess,
|
onSuccess,
|
||||||
kind
|
kind
|
||||||
}: ChannelPoliciesFormProps) => {
|
}: ChannelPoliciesFormProps) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const channelText = getChannelTextBasedOnKind(kind);
|
const channelText = getChannelTextBasedOnKind(kind);
|
||||||
@@ -104,10 +105,6 @@ export const ChannelPoliciesForm = ({
|
|||||||
return existing &&
|
return existing &&
|
||||||
(existing.eventKinds !== policy.eventKinds || existing.enabled !== policy.enabled);
|
(existing.eventKinds !== policy.eventKinds || existing.enabled !== policy.enabled);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const promises = kind === "notification"
|
const promises = kind === "notification"
|
||||||
? [
|
? [
|
||||||
policiesToAdd.length > 0 ? await createAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToAdd}) : null,
|
policiesToAdd.length > 0 ? await createAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToAdd}) : null,
|
||||||
@@ -132,7 +129,11 @@ export const ChannelPoliciesForm = ({
|
|||||||
if (failedActions.length > 0) throw new Error(failedActions[0].data.actionError?.message || "One or more operations failed");
|
if (failedActions.length > 0) throw new Error(failedActions[0].data.actionError?.message || "One or more operations failed");
|
||||||
return {success: true};
|
return {success: true};
|
||||||
},
|
},
|
||||||
onSuccess: () => { toast.success("Policies saved successfully"); router.refresh(); },
|
onSuccess: () => {
|
||||||
|
toast.success("Policies saved successfully");
|
||||||
|
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
onError: (error: any) => { toast.error(error.message || "Failed to save policies"); },
|
onError: (error: any) => { toast.error(error.message || "Failed to save policies"); },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ export const ChannelPoliciesModal = ({icon, kind, database, channels, organizati
|
|||||||
|
|
||||||
const channelsIds = channelsFiltered
|
const channelsIds = channelsFiltered
|
||||||
.map(channel => channel.id);
|
.map(channel => channel.id);
|
||||||
console.log(channelsIds);
|
|
||||||
const activeAlertPolicies = database.alertPolicies?.filter((policy) => channelsIds.includes(policy.notificationChannelId));
|
const activeAlertPolicies = database.alertPolicies?.filter((policy) => channelsIds.includes(policy.notificationChannelId));
|
||||||
const activeStoragePolicies = database.storagePolicies?.filter((policy) => channelsIds.includes(policy.storageChannelId));
|
const activeStoragePolicies = database.storagePolicies?.filter((policy) => channelsIds.includes(policy.storageChannelId));
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { updateDatabaseBackupPolicyAction } from "@/components/wrappers/dashboard/database/cron-button/cron.action";
|
import { updateDatabaseBackupPolicyAction } from "@/components/wrappers/dashboard/database/cron-button/cron.action";
|
||||||
import {Database} from "@/db/schema/07_database";
|
import {Database} from "@/db/schema/07_database";
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ export type CronButtonProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CronButton = (props: CronButtonProps) => {
|
export const CronButton = (props: CronButtonProps) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSwitched, setIsSwitched] = useState(props.database.backupPolicy !== null);
|
const [isSwitched, setIsSwitched] = useState(props.database.backupPolicy !== null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -27,6 +28,7 @@ export const CronButton = (props: CronButtonProps) => {
|
|||||||
updateDatabaseBackupPolicyAction({ databaseId: props.database.id, backupPolicy: value }),
|
updateDatabaseBackupPolicyAction({ databaseId: props.database.id, backupPolicy: value }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(`Method updated successfully.`);
|
toast.success(`Method updated successfully.`);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["database-data", props.database.id] });
|
||||||
router.refresh();
|
router.refresh();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {AdvancedCronSelect} from "./advanced-cron-select";
|
import {AdvancedCronSelect} from "./advanced-cron-select";
|
||||||
import {updateDatabaseBackupPolicyAction} from "@/components/wrappers/dashboard/database/cron-button/cron.action";
|
import {updateDatabaseBackupPolicyAction} from "@/components/wrappers/dashboard/database/cron-button/cron.action";
|
||||||
import {useMutation} from "@tanstack/react-query";
|
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
||||||
import {useState} from "react";
|
|
||||||
import {useRouter} from "next/navigation";
|
import {useRouter} from "next/navigation";
|
||||||
|
import {useState} from "react";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {Button} from "@/components/ui/button";
|
import {Button} from "@/components/ui/button";
|
||||||
import {Separator} from "@/components/ui/separator";
|
import {Separator} from "@/components/ui/separator";
|
||||||
@@ -15,6 +15,7 @@ export type CronInputProps = {
|
|||||||
|
|
||||||
export const CronInput = ({database, onSuccess}: CronInputProps) => {
|
export const CronInput = ({database, onSuccess}: CronInputProps) => {
|
||||||
const [cron, setCron] = useState<string>(database.backupPolicy ?? "* * * * *");
|
const [cron, setCron] = useState<string>(database.backupPolicy ?? "* * * * *");
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const updateBackupPolicy = useMutation({
|
const updateBackupPolicy = useMutation({
|
||||||
@@ -22,6 +23,7 @@ export const CronInput = ({database, onSuccess}: CronInputProps) => {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(`Cron updated successfully.`);
|
toast.success(`Cron updated successfully.`);
|
||||||
onSuccess?.()
|
onSuccess?.()
|
||||||
|
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
|
||||||
router.refresh();
|
router.refresh();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {DropZoneFile} from "@/components/wrappers/common/dropzone/dropzone-file";
|
import {DropZoneFile} from "@/components/wrappers/common/dropzone/dropzone-file";
|
||||||
import {useMutation} from "@tanstack/react-query";
|
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
||||||
|
import {useRouter} from "next/navigation";
|
||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import {Loader2} from "lucide-react";
|
import {Loader2} from "lucide-react";
|
||||||
import {useRouter} from "next/navigation";
|
|
||||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {uploadBackupAction} from "@/components/wrappers/dashboard/database/import/upload-backup.action";
|
import {uploadBackupAction} from "@/components/wrappers/dashboard/database/import/upload-backup.action";
|
||||||
@@ -16,6 +16,7 @@ type UploadRetentionZoneProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZoneProps) => {
|
export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZoneProps) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
@@ -37,6 +38,7 @@ export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZ
|
|||||||
if (inner?.success) {
|
if (inner?.success) {
|
||||||
toast.success(inner.actionSuccess?.message);
|
toast.success(inner.actionSuccess?.message);
|
||||||
onSuccessAction?.()
|
onSuccessAction?.()
|
||||||
|
queryClient.invalidateQueries({queryKey: ["database-data", databaseId]});
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast.error(inner?.actionError?.message);
|
toast.error(inner?.actionError?.message);
|
||||||
@@ -45,7 +47,7 @@ export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZ
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
toast.error("An error occurred while upload in the backup");
|
toast.error("An error occurred while upload in the backup");
|
||||||
} finally {
|
} finally {
|
||||||
router.refresh();
|
queryClient.invalidateQueries({queryKey: ["database-data", databaseId]});
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -66,7 +68,7 @@ export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZ
|
|||||||
) : (
|
) : (
|
||||||
<DropZoneFile
|
<DropZoneFile
|
||||||
accept={acceptDbImportFiles}
|
accept={acceptDbImportFiles}
|
||||||
maxSize={500 * 1024 * 1024}
|
maxSize={2 * 1024 * 1024 * 1024}
|
||||||
maxFiles={1}
|
maxFiles={1}
|
||||||
description="Import database backup"
|
description="Import database backup"
|
||||||
fileKind="Database file (.sql, .dump)"
|
fileKind="Database file (.sql, .dump)"
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ import {ServerActionResult} from "@/types/action-type";
|
|||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {Backup} from "@/db/schema/07_database";
|
import {Backup} from "@/db/schema/07_database";
|
||||||
import {getFileExtension} from "../../../../../../app/api/agent/[agentId]/backup/helpers";
|
|
||||||
import {v4 as uuidv4} from "uuid";
|
import {v4 as uuidv4} from "uuid";
|
||||||
import {eq} from "drizzle-orm";
|
import {eq} from "drizzle-orm";
|
||||||
import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action";
|
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
import {env} from "@/env.mjs";
|
|
||||||
import {storeBackupFiles} from "@/features/storages/helpers";
|
import {storeBackupFiles} from "@/features/storages/helpers";
|
||||||
|
import {getFileExtension} from "@/utils/common";
|
||||||
|
|
||||||
|
|
||||||
export const uploadBackupAction = userAction
|
export const uploadBackupAction = userAction
|
||||||
@@ -48,18 +46,6 @@ export const uploadBackupAction = userAction
|
|||||||
const fileName = `${uuid}${fileExtension}`;
|
const fileName = `${uuid}${fileExtension}`;
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
// const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
|
||||||
//
|
|
||||||
// if (!settings) {
|
|
||||||
// return {
|
|
||||||
// success: false,
|
|
||||||
// actionError: {
|
|
||||||
// message: "Settings not set",
|
|
||||||
// status: 500,
|
|
||||||
// cause: "Unknown error",
|
|
||||||
// },
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|
||||||
const [backup] = await db
|
const [backup] = await db
|
||||||
.insert(drizzleDb.schemas.backup)
|
.insert(drizzleDb.schemas.backup)
|
||||||
@@ -70,30 +56,8 @@ export const uploadBackupAction = userAction
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
||||||
await storeBackupFiles(backup, database, buffer, fileName)
|
await storeBackupFiles(backup, database, buffer, fileName)
|
||||||
|
|
||||||
|
|
||||||
// let success: boolean, message: string, filePath: string;
|
|
||||||
//
|
|
||||||
// const result =
|
|
||||||
// settings.storage === "local"
|
|
||||||
// ? await uploadLocalPrivate(fileName, buffer)
|
|
||||||
// : await uploadS3Private(`${database.project?.slug}/${fileName}`, buffer, env.S3_BUCKET_NAME!);
|
|
||||||
//
|
|
||||||
// ({success, message, filePath} = result);
|
|
||||||
//
|
|
||||||
// if (!success) {
|
|
||||||
// return {
|
|
||||||
// success: false,
|
|
||||||
// actionError: {
|
|
||||||
// message: "An error has occurred while uploading file",
|
|
||||||
// status: 500,
|
|
||||||
// cause: "Unknown error",
|
|
||||||
// },
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
value: backup,
|
value: backup,
|
||||||
|
|||||||
+4
-2
@@ -10,11 +10,11 @@ import {
|
|||||||
useZodForm
|
useZodForm
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import {RetentionSettings, RetentionSettingsSchema} from "./backup-retention-settings.schema";
|
import {RetentionSettings, RetentionSettingsSchema} from "./backup-retention-settings.schema";
|
||||||
import {useMutation} from "@tanstack/react-query";
|
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
||||||
|
import {useRouter} from "next/navigation";
|
||||||
import {updateOrCreateBackupRetentionPolicyAction} from "./backup-retention-settings.action";
|
import {updateOrCreateBackupRetentionPolicyAction} from "./backup-retention-settings.action";
|
||||||
import {DatabaseWith, RetentionPolicy} from "@/db/schema/07_database";
|
import {DatabaseWith, RetentionPolicy} from "@/db/schema/07_database";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {useRouter} from "next/navigation";
|
|
||||||
import {RadioGroup, RadioGroupItem} from "@/components/ui/radio-group";
|
import {RadioGroup, RadioGroupItem} from "@/components/ui/radio-group";
|
||||||
import {Badge} from "@/components/ui/badge";
|
import {Badge} from "@/components/ui/badge";
|
||||||
import {Separator} from "@/components/ui/separator";
|
import {Separator} from "@/components/ui/separator";
|
||||||
@@ -28,6 +28,7 @@ export type BackupRetentionSettingsFormProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRetentionSettingsFormProps) => {
|
export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRetentionSettingsFormProps) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const defaultValuesFormatted: RetentionSettings = {
|
const defaultValuesFormatted: RetentionSettings = {
|
||||||
@@ -55,6 +56,7 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet
|
|||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Retention policy updated successfully.");
|
toast.success("Retention policy updated successfully.");
|
||||||
|
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
|
||||||
router.refresh();
|
router.refresh();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
|
|||||||
+2
-2
@@ -49,7 +49,7 @@ export function BackupRetentionSettings({database}: BackupRetentionSettingsProps
|
|||||||
yearly: 3,
|
yearly: 3,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const router = useRouter()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const updateRetentionPolicy = useMutation({
|
const updateRetentionPolicy = useMutation({
|
||||||
mutationFn: async (payload: RetentionSettings) => await updateOrCreateBackupRetentionPolicyAction({
|
mutationFn: async (payload: RetentionSettings) => await updateOrCreateBackupRetentionPolicyAction({
|
||||||
@@ -58,7 +58,7 @@ export function BackupRetentionSettings({database}: BackupRetentionSettingsProps
|
|||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Retention policy updated successfully.")
|
toast.success("Retention policy updated successfully.")
|
||||||
router.refresh()
|
queryClient.invalidateQueries({queryKey: ["database-data", database.id]})
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error("An error occurred while updating retention policy.")
|
toast.error("An error occurred while updating retention policy.")
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {Skeleton} from "@/components/ui/skeleton"
|
|||||||
|
|
||||||
export function OrganizationCombobox() {
|
export function OrganizationCombobox() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const {isMobile, state} = useSidebar()
|
const {state} = useSidebar()
|
||||||
const {data: organizations, isPending: isPendingList, refetch} = authClient.useListOrganizations()
|
const {data: organizations, isPending: isPendingList, refetch} = authClient.useListOrganizations()
|
||||||
const {
|
const {
|
||||||
data: activeOrganization,
|
data: activeOrganization,
|
||||||
@@ -75,7 +75,7 @@ export function OrganizationCombobox() {
|
|||||||
>
|
>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"flex aspect-square size-8 items-center justify-center rounded-lg text-white shadow-sm transition-transform duration-200",
|
"flex aspect-square size-8 items-center justify-center rounded-lg text-white shadow-sm transition-transform duration-200",
|
||||||
activeOrganization?.logo ? "bg-transparent" : "bg-orange-500",
|
activeOrganization?.logo ? "bg-transparent" : "bg-primary",
|
||||||
isOpen && "scale-105"
|
isOpen && "scale-105"
|
||||||
)}>
|
)}>
|
||||||
{activeOrganization?.logo ? (
|
{activeOrganization?.logo ? (
|
||||||
@@ -95,7 +95,7 @@ export function OrganizationCombobox() {
|
|||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className="min-w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none"
|
className="w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none"
|
||||||
align="start"
|
align="start"
|
||||||
side="bottom"
|
side="bottom"
|
||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
@@ -109,33 +109,33 @@ export function OrganizationCombobox() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"group gap-2 p-1 cursor-pointer rounded-lg mb-1 last:mb-0 transition-colors",
|
"group gap-2 p-1 cursor-pointer rounded-lg mb-1 last:mb-0 transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "bg-orange-500/10 text-orange-600 dark:text-orange-400 border border-orange-500/20"
|
? "bg-primary/10 text-primary border border-primary/20"
|
||||||
: "focus:bg-accent hover:bg-accent/50 border border-transparent"
|
: "focus:bg-accent hover:bg-accent/50 border border-transparent"
|
||||||
)}>
|
)}>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"flex size-9 items-center justify-center rounded-md border shadow-sm transition-all group-hover:shadow-md",
|
"flex size-9 shrink-0 items-center justify-center rounded-md border shadow-sm transition-all group-hover:shadow-md",
|
||||||
org.logo ? "bg-transparent border-transparent" : "",
|
org.logo ? "bg-transparent border-transparent" : "",
|
||||||
isActive && !org.logo ? "bg-orange-500 text-white border-orange-500/30" : "bg-muted/50 border-border"
|
isActive && !org.logo ? "bg-primary text-primary-foreground border-primary/30" : "bg-muted/50 border-border"
|
||||||
)}>
|
)}>
|
||||||
{org.logo ? (
|
{org.logo ? (
|
||||||
<img src={org.logo} alt={org.name} className="size-9 rounded-md object-cover"/>
|
<img src={org.logo} alt={org.name} className="size-9 rounded-md object-cover"/>
|
||||||
) : (
|
) : (
|
||||||
<Building2 className={cn(
|
<Building2 className={cn(
|
||||||
"size-5",
|
"size-5",
|
||||||
isActive ? "text-white" : "text-muted-foreground"
|
isActive ? "text-primary-foreground" : "text-muted-foreground"
|
||||||
)}/>
|
)}/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-sm max-w-42.5 truncate font-medium leading-none",
|
"text-sm max-w-42.5 truncate font-medium leading-none",
|
||||||
isActive ? "text-orange-600 dark:text-orange-400" : ""
|
isActive ? "text-primary" : ""
|
||||||
)}>{org.name}</span>
|
)}>{org.name}</span>
|
||||||
</div>
|
</div>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<div
|
<div
|
||||||
className="ml-auto flex size-5 items-center justify-center rounded-full bg-orange-500 shadow-sm">
|
className="ml-auto flex size-5 items-center justify-center rounded-full bg-primary shadow-sm">
|
||||||
<Check className="size-3 text-white" strokeWidth={3}/>
|
<Check className="size-3 text-primary-foreground" strokeWidth={3}/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -150,7 +150,7 @@ export function OrganizationCombobox() {
|
|||||||
className="flex size-9 items-center justify-center rounded-md border border-dashed border-muted-foreground/30 bg-background transition-colors group-hover:border-primary/50 group-hover:bg-primary/5">
|
className="flex size-9 items-center justify-center rounded-md border border-dashed border-muted-foreground/30 bg-background transition-colors group-hover:border-primary/50 group-hover:bg-primary/5">
|
||||||
<Plus className="size-4"/>
|
<Plus className="size-4"/>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium">Create new organization</div>
|
<div className="font-medium">Create organization</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export default function TwoFactorForm({ onSuccess, onSuccessData }: TwoFactorFor
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 pt-2">
|
<div className="flex flex-col gap-3 pt-2">
|
||||||
<Button type="submit" disabled={isPending}>
|
<Button type="submit" className="h-11" disabled={isPending}>
|
||||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
Verify
|
Verify
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {useEffect} from "react";
|
|
||||||
import {Button} from "@/components/ui/button";
|
import {Button} from "@/components/ui/button";
|
||||||
import {Input} from "@/components/ui/input";
|
import {Input} from "@/components/ui/input";
|
||||||
import {AlertCircle, Loader2} from "lucide-react";
|
import {AlertCircle, Loader2} from "lucide-react";
|
||||||
@@ -22,18 +21,6 @@ export function ProfileAccount({user}: ProfileAccountProps) {
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let interval: NodeJS.Timeout;
|
|
||||||
|
|
||||||
interval = setInterval(async () => {
|
|
||||||
router.refresh();
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (interval) clearInterval(interval);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const emailForm = useZodForm({
|
const emailForm = useZodForm({
|
||||||
schema: EmailSchema,
|
schema: EmailSchema,
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function ProfileAppearance() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ThemeSelector() {
|
function ThemeSelector() {
|
||||||
const {theme, setTheme} = useTheme();
|
const {theme} = useTheme();
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -39,7 +39,6 @@ function ThemeSelector() {
|
|||||||
isActive ? "border-primary bg-primary/5" : "border-muted/40"
|
isActive ? "border-primary bg-primary/5" : "border-muted/40"
|
||||||
)}
|
)}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
// setTheme(item.value)
|
|
||||||
await authClient.updateUser({theme: item.value});
|
await authClient.updateUser({theme: item.value});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import {DataTable} from "@/components/wrappers/common/table/data-table";
|
|||||||
import {useMemo, useState} from "react";
|
import {useMemo, useState} from "react";
|
||||||
import {Backup, BackupWith, DatabaseWith} from "@/db/schema/07_database";
|
import {Backup, BackupWith, DatabaseWith} from "@/db/schema/07_database";
|
||||||
import {Setting} from "@/db/schema/01_setting";
|
import {Setting} from "@/db/schema/01_setting";
|
||||||
import {useMutation} from "@tanstack/react-query";
|
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {useRouter} from "next/navigation";
|
|
||||||
import {MemberWithUser} from "@/db/schema/03_organization";
|
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||||
import {deleteBackupAction} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action";
|
import {deleteBackupAction} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action";
|
||||||
|
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
||||||
|
|
||||||
|
|
||||||
type DatabaseBackupListProps = {
|
type DatabaseBackupListProps = {
|
||||||
@@ -32,7 +32,12 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
const [selectedFilters, setSelectedFilters] = useState<FilterItem[]>([items[1]]);
|
const [selectedFilters, setSelectedFilters] = useState<FilterItem[]>([items[1]]);
|
||||||
const router = useRouter();
|
const [isActionsOpen, setIsActionsOpen] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return backupColumns(props.isAlreadyRestore, props.settings, props.database, props.activeMember);
|
||||||
|
}, [props.isAlreadyRestore, props.activeMember.id, props.activeMember.role]);
|
||||||
|
|
||||||
const filteredBackups = useMemo(() => {
|
const filteredBackups = useMemo(() => {
|
||||||
if (!props.backups) return [];
|
if (!props.backups) return [];
|
||||||
@@ -64,19 +69,12 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
|
|||||||
mutationFn: async (backups: Backup[]) => {
|
mutationFn: async (backups: Backup[]) => {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
backups.map(async (backup) => {
|
backups.map(async (backup) => {
|
||||||
if (backup.deletedAt == null) {
|
if (backup.deletedAt == null || backup.status == "ongoing") {
|
||||||
|
|
||||||
const backupDeleted = await deleteBackupAction({
|
const backupDeleted = await deleteBackupAction({
|
||||||
databaseId: backup.databaseId,
|
databaseId: backup.databaseId,
|
||||||
backupId: backup.id,
|
backupId: backup.id,
|
||||||
})
|
})
|
||||||
// const backupDeleted = await deleteBackupAction({
|
|
||||||
// backupId: backup.id,
|
|
||||||
// databaseId: backup.databaseId,
|
|
||||||
// status: backup.status,
|
|
||||||
// file: backup.file ?? "",
|
|
||||||
// projectSlug: props.database?.project?.slug!
|
|
||||||
// });
|
|
||||||
return {
|
return {
|
||||||
success: backupDeleted?.data?.success,
|
success: backupDeleted?.data?.success,
|
||||||
message: backupDeleted?.data?.success
|
message: backupDeleted?.data?.success
|
||||||
@@ -98,7 +96,7 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
|
|||||||
toast.error(result.message);
|
toast.error(result.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
router.refresh();
|
queryClient.invalidateQueries({queryKey: ["database-data", props.database.id]});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,7 +105,7 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
|
|||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
enableSelect={!isMember}
|
enableSelect={!isMember}
|
||||||
columns={backupColumns(props.isAlreadyRestore, props.settings, props.database, props.activeMember)}
|
columns={columns}
|
||||||
data={filteredBackups}
|
data={filteredBackups}
|
||||||
enablePagination
|
enablePagination
|
||||||
selectedActions={(rows) => (
|
selectedActions={(rows) => (
|
||||||
@@ -115,28 +113,38 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
|
|||||||
<div className="flex justify-start md:justify-between gap-3 md:gap-0 items-center w-full ml-0">
|
<div className="flex justify-start md:justify-between gap-3 md:gap-0 items-center w-full ml-0">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{!isMember && (
|
{!isMember && (
|
||||||
<DropdownMenu>
|
<DropdownMenu open={isActionsOpen} onOpenChange={setIsActionsOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<ButtonWithLoading
|
<ButtonWithLoading
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsActionsOpen(true);
|
||||||
}}
|
}}
|
||||||
disabled={rows.length === 0 || mutationDeleteBackups.isPending}
|
disabled={rows.length === 0 || mutationDeleteBackups.isPending}
|
||||||
icon={<MoreHorizontal/>}
|
icon={<MoreHorizontal/>}
|
||||||
isPending={mutationDeleteBackups.isPending}
|
isPending={mutationDeleteBackups.isPending}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
type="button"
|
||||||
>Actions</ButtonWithLoading>
|
>Actions</ButtonWithLoading>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
<DropdownMenuItem
|
<ButtonWithConfirm
|
||||||
onClick={async () => {
|
onConfirm={() => {
|
||||||
await mutationDeleteBackups.mutateAsync(rows)
|
mutationDeleteBackups.mutate(rows);
|
||||||
|
setIsActionsOpen(false);
|
||||||
}}
|
}}
|
||||||
className="text-red-600 focus:text-red-700"
|
onCancel={() => setIsActionsOpen(false)}
|
||||||
|
title="Delete backups?"
|
||||||
|
description="Are you sure you want to delete the selected backups? This action cannot be undone."
|
||||||
|
confirmButtonText="Yes, delete"
|
||||||
|
cancelButtonText="Cancel"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-2"/>
|
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||||
Delete Selected
|
<Trash2 className="me-2 h-4 w-4 text-red-600"/>
|
||||||
|
<span className="text-red-600">Delete Selected</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</ButtonWithConfirm>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,17 @@ import {Setting} from "@/db/schema/01_setting";
|
|||||||
import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database";
|
import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database";
|
||||||
import {MemberWithUser} from "@/db/schema/03_organization";
|
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||||
import {useBackupModal} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
import {useBackupModal} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
||||||
|
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
|
||||||
|
import {useQuery, useQueryClient} from "@tanstack/react-query";
|
||||||
|
import {getDatabaseDataAction} from "@/components/wrappers/dashboard/database/backup/actions/get-data.action";
|
||||||
|
import {PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||||
|
import {capitalizeFirstLetter} from "@/utils/text";
|
||||||
|
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
|
||||||
|
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
|
||||||
|
import {ChannelPoliciesModal} from "@/components/wrappers/dashboard/database/channels-policy/policy-modal";
|
||||||
|
import {HardDrive, Megaphone} from "lucide-react";
|
||||||
|
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
|
||||||
|
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
|
||||||
|
|
||||||
|
|
||||||
export type DatabaseContentProps = {
|
export type DatabaseContentProps = {
|
||||||
@@ -13,27 +24,121 @@ export type DatabaseContentProps = {
|
|||||||
restorations: Restoration[],
|
restorations: Restoration[],
|
||||||
isAlreadyRestore: boolean,
|
isAlreadyRestore: boolean,
|
||||||
database: DatabaseWith,
|
database: DatabaseWith,
|
||||||
activeMember: MemberWithUser
|
activeMember: MemberWithUser,
|
||||||
|
totalBackups: number,
|
||||||
|
availableBackups: number,
|
||||||
|
successRate: number | null,
|
||||||
|
organizationId: string,
|
||||||
|
activeOrganizationChannels: any[],
|
||||||
|
activeOrganizationStorageChannels: any[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const DatabaseContent = ({
|
export const DatabaseContent = (props: DatabaseContentProps) => {
|
||||||
settings,
|
|
||||||
backups,
|
|
||||||
activeMember,
|
|
||||||
isAlreadyRestore,
|
|
||||||
restorations,
|
|
||||||
database
|
|
||||||
}: DatabaseContentProps) => {
|
|
||||||
const {} = useBackupModal();
|
const {} = useBackupModal();
|
||||||
|
|
||||||
|
|
||||||
|
const {data} = useQuery({
|
||||||
|
queryKey: ["database-data", props.database.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const result = await getDatabaseDataAction({databaseId: props.database.id});
|
||||||
|
return result?.data;
|
||||||
|
},
|
||||||
|
initialData: {
|
||||||
|
// TODO : to be patched
|
||||||
|
// @ts-ignore
|
||||||
|
database: {
|
||||||
|
...props.database,
|
||||||
|
project: props.database.project ?? null,
|
||||||
|
},
|
||||||
|
backups: props.backups,
|
||||||
|
restorations: props.restorations,
|
||||||
|
activeOrganizationChannels: props.activeOrganizationChannels,
|
||||||
|
activeOrganizationStorageChannels: props.activeOrganizationStorageChannels,
|
||||||
|
stats: {
|
||||||
|
totalBackups: props.totalBackups,
|
||||||
|
availableBackups: props.availableBackups,
|
||||||
|
successRate: props.successRate
|
||||||
|
}
|
||||||
|
},
|
||||||
|
staleTime: 0,
|
||||||
|
gcTime: 0,
|
||||||
|
refetchInterval: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const database = data?.database ?? props.database;
|
||||||
|
const backups = data?.backups ?? props.backups;
|
||||||
|
const restorations = data?.restorations ?? props.restorations;
|
||||||
|
const activeOrganizationChannels = data?.activeOrganizationChannels ?? props.activeOrganizationChannels;
|
||||||
|
const activeOrganizationStorageChannels = data?.activeOrganizationStorageChannels ?? props.activeOrganizationStorageChannels;
|
||||||
|
const stats = data?.stats ?? {
|
||||||
|
totalBackups: props.totalBackups,
|
||||||
|
availableBackups: props.availableBackups,
|
||||||
|
successRate: props.successRate
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
|
||||||
|
const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
|
||||||
|
|
||||||
|
const isMember = props.activeMember.role === "member";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="justify-between gap-2 sm:flex">
|
||||||
|
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||||
|
<div className="min-w-full md:min-w-fit ">
|
||||||
|
{capitalizeFirstLetter(database.name)}
|
||||||
|
</div>
|
||||||
|
{!isMember && (
|
||||||
|
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RetentionPolicySheet database={database}/>
|
||||||
|
<CronButton database={database}/>
|
||||||
|
<ChannelPoliciesModal
|
||||||
|
database={database}
|
||||||
|
kind={"notification"}
|
||||||
|
icon={<Megaphone/>}
|
||||||
|
channels={activeOrganizationChannels}
|
||||||
|
organizationId={props.organizationId}
|
||||||
|
/>
|
||||||
|
<ChannelPoliciesModal
|
||||||
|
database={database}
|
||||||
|
icon={<HardDrive/>}
|
||||||
|
kind={"storage"}
|
||||||
|
channels={activeOrganizationStorageChannels}
|
||||||
|
organizationId={props.organizationId}
|
||||||
|
/>
|
||||||
|
<ImportModal database={database}/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BackupButton disable={isAlreadyBackup} databaseId={database.id}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageTitle>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{database.description && (
|
||||||
|
<PageDescription className="mt-5 sm:mt-0">{database.description}</PageDescription>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PageContent className="flex flex-col w-full h-full">
|
||||||
|
<DatabaseKpi
|
||||||
|
successRate={stats.successRate}
|
||||||
|
database={database}
|
||||||
|
availableBackups={stats.availableBackups}
|
||||||
|
totalBackups={stats.totalBackups}
|
||||||
|
/>
|
||||||
<DatabaseBackupActionsModal/>
|
<DatabaseBackupActionsModal/>
|
||||||
<DatabaseTabs activeMember={activeMember} settings={settings} database={database}
|
<DatabaseTabs
|
||||||
|
activeMember={props.activeMember}
|
||||||
|
settings={props.settings}
|
||||||
|
database={database}
|
||||||
isAlreadyRestore={isAlreadyRestore}
|
isAlreadyRestore={isAlreadyRestore}
|
||||||
backups={backups}
|
backups={backups}
|
||||||
restorations={restorations}/>
|
restorations={restorations}
|
||||||
|
/>
|
||||||
|
</PageContent>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,6 @@ export type DatabaseKpiPro = {
|
|||||||
totalBackups: number;
|
totalBackups: number;
|
||||||
availableBackups: number;
|
availableBackups: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DatabaseKpi = (props: DatabaseKpiPro) => {
|
export const DatabaseKpi = (props: DatabaseKpiPro) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row sm:justify-between gap-8 mb-6">
|
<div className="flex flex-col sm:flex-row sm:justify-between gap-8 mb-6">
|
||||||
@@ -42,7 +41,7 @@ export const DatabaseKpi = (props: DatabaseKpiPro) => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{props.successRate ? `${props.successRate.toFixed(0)} %` : "Unavailable"}
|
{typeof props.successRate === 'number' ? `${props.successRate.toFixed(0)} %` : "Unavailable"}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">Backup success rate</p>
|
<p className="text-xs text-muted-foreground">Backup success rate</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -5,21 +5,28 @@ import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger
|
|||||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||||
import {MoreHorizontal, Trash2} from "lucide-react";
|
import {MoreHorizontal, Trash2} from "lucide-react";
|
||||||
import {Restoration} from "@/db/schema/07_database";
|
import {Restoration} from "@/db/schema/07_database";
|
||||||
import {useMutation} from "@tanstack/react-query";
|
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
||||||
import {deleteRestoreAction} from "@/features/dashboard/restore/restore.action";
|
import {deleteRestoreAction} from "@/features/dashboard/restore/restore.action";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {useRouter} from "next/navigation";
|
|
||||||
import {MemberWithUser} from "@/db/schema/03_organization";
|
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||||
|
import {useMemo, useState} from "react";
|
||||||
|
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
||||||
|
|
||||||
|
|
||||||
type DatabaseRestoreListProps = {
|
type DatabaseRestoreListProps = {
|
||||||
isAlreadyRestore: boolean;
|
isAlreadyRestore: boolean;
|
||||||
restorations: Restoration[];
|
restorations: Restoration[];
|
||||||
activeMember: MemberWithUser
|
activeMember: MemberWithUser;
|
||||||
|
databaseId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
|
export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
|
||||||
const router = useRouter();
|
const queryClient = useQueryClient();
|
||||||
|
const [isActionsOpen, setIsActionsOpen] = useState(false);
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return restoreColumns(props.isAlreadyRestore, props.activeMember);
|
||||||
|
}, [props.isAlreadyRestore, props.activeMember.id, props.activeMember.role]);
|
||||||
|
|
||||||
const mutationDeleteRestorations = useMutation({
|
const mutationDeleteRestorations = useMutation({
|
||||||
mutationFn: async (restorations: Restoration[]) => {
|
mutationFn: async (restorations: Restoration[]) => {
|
||||||
@@ -46,7 +53,7 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
|
|||||||
toast.error(result.message);
|
toast.error(result.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
router.refresh();
|
queryClient.invalidateQueries({queryKey: ["database-data", props.databaseId]});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const isMember = props.activeMember.role === "member";
|
const isMember = props.activeMember.role === "member";
|
||||||
@@ -55,35 +62,48 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
|
|||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
enableSelect={!isMember}
|
enableSelect={!isMember}
|
||||||
columns={restoreColumns(props.isAlreadyRestore, props.activeMember)}
|
columns={columns}
|
||||||
data={props.restorations}
|
data={props.restorations}
|
||||||
enablePagination
|
enablePagination
|
||||||
selectedActions={(rows) => (
|
selectedActions={(rows) => (
|
||||||
<>
|
<>
|
||||||
{!isMember && (
|
{!isMember && (
|
||||||
<DropdownMenu>
|
<DropdownMenu open={isActionsOpen} onOpenChange={setIsActionsOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<ButtonWithLoading
|
<ButtonWithLoading
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsActionsOpen(true);
|
||||||
}}
|
}}
|
||||||
disabled={rows.length === 0 || mutationDeleteRestorations.isPending}
|
disabled={rows.length === 0 || mutationDeleteRestorations.isPending}
|
||||||
icon={<MoreHorizontal/>}
|
icon={<MoreHorizontal/>}
|
||||||
isPending={mutationDeleteRestorations.isPending}
|
isPending={mutationDeleteRestorations.isPending}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
type="button"
|
||||||
>Actions</ButtonWithLoading>
|
>Actions</ButtonWithLoading>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
<DropdownMenuItem
|
<ButtonWithConfirm
|
||||||
onClick={async () => {
|
onConfirm={() => {
|
||||||
await mutationDeleteRestorations.mutateAsync(rows)
|
mutationDeleteRestorations.mutate(rows)
|
||||||
|
setIsActionsOpen(false);
|
||||||
}}
|
}}
|
||||||
|
onCancel={() => setIsActionsOpen(false)}
|
||||||
|
title="Delete restorations?"
|
||||||
|
description="Are you sure you want to delete the selected restorations? This action cannot be undone."
|
||||||
|
confirmButtonText="Yes, delete"
|
||||||
|
cancelButtonText="Cancel"
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
disabled={props.isAlreadyRestore}
|
disabled={props.isAlreadyRestore}
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
className="text-red-600 focus:text-red-700"
|
className="text-red-600 focus:text-red-700"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-2"/>
|
<Trash2 className="w-4 h-4 mr-2"/>
|
||||||
Delete Selected
|
Delete Selected
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</ButtonWithConfirm>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {useRouter, useSearchParams} from "next/navigation";
|
import {useRouter, useSearchParams} from "next/navigation";
|
||||||
import {eventUpdate} from "@/types/events";
|
|
||||||
import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database";
|
import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database";
|
||||||
import {Setting} from "@/db/schema/01_setting";
|
import {Setting} from "@/db/schema/01_setting";
|
||||||
import {DatabaseBackupList} from "@/components/wrappers/dashboard/projects/database/database-backup-list";
|
import {DatabaseBackupList} from "@/components/wrappers/dashboard/projects/database/database-backup-list";
|
||||||
@@ -25,21 +24,6 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => {
|
|||||||
|
|
||||||
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "backup");
|
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "backup");
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const eventSource = new EventSource("/api/events");
|
|
||||||
eventSource.addEventListener("modification", (event) => {
|
|
||||||
const data: eventUpdate = JSON.parse(event.data);
|
|
||||||
if (data.update) {
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
eventSource.close();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newTab = searchParams.get("tab") ?? "backup";
|
const newTab = searchParams.get("tab") ?? "backup";
|
||||||
setTab(newTab);
|
setTab(newTab);
|
||||||
@@ -69,6 +53,7 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => {
|
|||||||
isAlreadyRestore={props.isAlreadyRestore}
|
isAlreadyRestore={props.isAlreadyRestore}
|
||||||
restorations={props.restorations}
|
restorations={props.restorations}
|
||||||
activeMember={props.activeMember}
|
activeMember={props.activeMember}
|
||||||
|
databaseId={props.database.id}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ export const ProjectCard = (props: projectCardProps) => {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/projects/${project.id}`}
|
href={`/dashboard/projects/${project.id}`}
|
||||||
className="group block transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
|
className="group block transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-orange-500/50 focus-visible:ring-offset-2 rounded-xl"
|
||||||
>
|
>
|
||||||
<Card className="relative h-full flex flex-col p-4 transition-all border-border/50 bg-card hover:bg-accent/50 hover:border-primary/50 group-hover:shadow-lg overflow-hidden gap-0">
|
<Card className="relative h-full flex flex-col p-4 transition-all duration-300 border-border/50 bg-card hover:bg-orange-500/[0.02] hover:border-orange-500/30 group-hover:shadow-md overflow-hidden gap-0">
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-primary/10 text-primary group-hover:bg-primary group-hover:text-primary-foreground transition-all duration-300 shadow-inner">
|
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-orange-500/10 text-orange-600 dark:text-orange-400 group-hover:bg-orange-500/20 transition-all duration-300">
|
||||||
<Folder className="w-8 h-8" />
|
<Folder className="w-8 h-8" />
|
||||||
</div>
|
</div>
|
||||||
<Badge className="text-[10px] font-medium px-2 py-1 rounded-lg bg-secondary/50 text-foreground">{dbCount} {dbCount === 1 ? "Database" : "Databases"}
|
<Badge className="text-[10px] font-medium px-2 py-1 rounded-lg bg-secondary/50 text-foreground">{dbCount} {dbCount === 1 ? "Database" : "Databases"}
|
||||||
@@ -31,7 +31,7 @@ export const ProjectCard = (props: projectCardProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 flex-1">
|
<div className="flex flex-col gap-2 flex-1">
|
||||||
<h3 className="text-lg font-black text-foreground group-hover:text-primary transition-colors line-clamp-1 tracking-tight">
|
<h3 className="text-lg font-black text-foreground group-hover:text-orange-500 transition-colors line-clamp-1 tracking-tight">
|
||||||
{project.name}
|
{project.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
|
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
|
||||||
@@ -40,8 +40,8 @@ export const ProjectCard = (props: projectCardProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-between pt-3 border-t border-border/50">
|
<div className="mt-4 flex items-center justify-between pt-3 border-t border-border/50">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-primary opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-x-[-10px] group-hover:translate-x-0">View Project</span>
|
<span className="text-[10px] font-bold uppercase tracking-widest text-orange-500 opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-x-[-10px] group-hover:translate-x-0">View Project</span>
|
||||||
<div className="flex items-center gap-1 text-muted-foreground group-hover:text-primary transition-colors">
|
<div className="flex items-center gap-1 text-muted-foreground group-hover:text-orange-500 transition-colors">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest group-hover:hidden">Details</span>
|
<span className="text-[10px] font-bold uppercase tracking-widest group-hover:hidden">Details</span>
|
||||||
<ChevronRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform" />
|
<ChevronRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export function PercentageLineChart(props: percentageLineChartProps) {
|
|||||||
<YAxis
|
<YAxis
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
domain={[0, 100]}
|
domain={[0, 100]}
|
||||||
tickFormatter={(tick) => `${tick}%`}
|
tickFormatter={(tick) => `${Number(tick).toFixed(0)}%`}
|
||||||
/>
|
/>
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
defaultIndex={1}
|
defaultIndex={1}
|
||||||
@@ -112,7 +112,7 @@ function PourcentTooltip({
|
|||||||
<span className="h-2 w-2 rounded-full bg-[#fc6504]"/>
|
<span className="h-2 w-2 rounded-full bg-[#fc6504]"/>
|
||||||
<span className="text-muted-foreground">Success Rate :</span>
|
<span className="text-muted-foreground">Success Rate :</span>
|
||||||
<span className="ml-auto font-semibold">
|
<span className="ml-auto font-semibold">
|
||||||
{data.successRate} %
|
{data.successRate.toFixed(0)} %
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "settings" ADD COLUMN "encryption" boolean DEFAULT false;
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user