Compare commits

...
69 Commits
Author SHA1 Message Date
charlesgauthereau 2a4af58a51 Merge remote-tracking branch 'origin/dev' into dev 2026-02-16 15:37:37 +01:00
charlesgauthereau b12f8ca044 chore(release): 1.2.7-rc.2 2026-02-16 15:37:06 +01:00
Charles GTEandGitHub 519b3abd1d Merge pull request #84 from Portabase/fix/twice-email-session
fix: twice email sent by better auth
2026-02-16 15:36:06 +01:00
charlesgauthereau e08a5bd6b6 fix: twice email sent by better auth 2026-02-16 15:34:31 +01:00
Killian LarcherandGitHub f6c597b42d Merge pull request #83 from Portabase/fix/optional-csp
Conditionally enable UPGRADE_INSECURE_REQUESTS based on PROJECT_URL scheme
2026-02-16 15:23:49 +01:00
killianlarcher f3b9032174 chore(release): 1.2.7-rc.1 2026-02-16 13:23:36 +01:00
killianlarcher 44b4ee2953 fix(csp): Conditionally sets UPGRADE_INSECURE_REQUESTS based on the PROJECT_URL scheme. 2026-02-16 13:21:09 +01:00
killianlarcher b77f79f42d fix(csp): Conditionally sets UPGRADE_INSECURE_REQUESTS based on the PROJECT_URL scheme. 2026-02-16 13:18:58 +01:00
charlesgauthereau 85f4cda752 fix: README.md 2026-02-15 11:49:01 +01:00
charlesgauthereau 1fd2f553ed chore(release): 1.2.6 2026-02-15 10:59:43 +01:00
Charles GTEandGitHub d68170eda8 Merge pull request #79 from Portabase/dev
Dev
2026-02-15 10:59:09 +01:00
Charles GTEandGitHub 51f27226e7 Merge branch 'main' into dev 2026-02-15 10:48:37 +01:00
charlesgauthereau d54c366323 chore(release): 1.2.6-rc.2 2026-02-15 10:21:24 +01:00
Charles GTEandGitHub 0e7cb9fbab Merge pull request #78 from Portabase/feat/internal-database
feat/internal-database
2026-02-15 10:20:25 +01:00
charlesgauthereau 63e83032f1 fix: working on adding internal postgres database. 2026-02-15 10:19:28 +01:00
charlesgauthereau b4da6fdd9f fix: working on adding internal postgres database. 2026-02-14 22:23:04 +01:00
charlesgauthereau 12535a59e8 fix: docker-compose.prod.yml 2026-02-14 19:22:24 +01:00
Charles GTEandGitHub b345eda7aa Update README.md 2026-02-14 09:32:02 +01:00
Charles GTEandGitHub 660c1dcadf Merge pull request #76 from Portabase/fix/backup-delete
fix: bug ongoing backup deletion
2026-02-13 22:41:48 +01:00
charlesgauthereau be66856034 chore(release): 1.2.6-rc.1 2026-02-13 22:41:29 +01:00
charlesgauthereau 80e3027107 fix: bug ongoing backup deletion 2026-02-13 22:40:44 +01:00
charlesgauthereau 362ca53efb fix: console.log 2026-02-13 15:58:53 +01:00
charlesgauthereau 7ab213e5e4 chore(release): 1.2.5 2026-02-13 14:35:11 +01:00
Charles GTEandGitHub f0eafb1162 Merge pull request #74 from Portabase/dev
dev
2026-02-13 14:31:52 +01:00
charlesgauthereau ad5baa7d6f Merge branch 'main' into dev 2026-02-13 14:31:03 +01:00
Charles GTEandGitHub 5840e30afb Merge pull request #73 from Portabase/feat/ui-ux-refactoring
feat/ui-ux-refactoring
2026-02-13 14:30:16 +01:00
charlesgauthereau 8f95606dd7 fix: review 2026-02-13 14:28:55 +01:00
Théo LAGACHE ca9bc7ae94 fix: policies reload 2026-02-13 11:24:53 +01:00
charlesgauthereau 288a40e697 chore(release): 1.2.5-rc.16 2026-02-12 20:25:29 +01:00
Charles GTEandGitHub fa91e8c289 Merge pull request #72 from Portabase/fix/encryption
fix/encryption
2026-02-12 20:20:25 +01:00
charlesgauthereau 4ab5fe4c6f fix: .gitignore 2026-02-12 20:18:56 +01:00
charlesgauthereau d87782d180 fix: Adding encryption of the settings. 2026-02-12 20:18:26 +01:00
charlesgauthereau 1c97c97fa1 fix: refactoring. 2026-02-12 19:07:34 +01:00
charlesgauthereau 2426166676 fix: working on AEA-GCM encryption. 2026-02-12 18:46:11 +01:00
Théo LAGACHE 6f6c1c7c52 fix 2026-02-12 15:48:51 +01:00
charlesgauthereau 7b9cb2ecb9 Merge remote-tracking branch 'origin/feat/fix-react-cve' into feat/agent-update 2026-02-11 19:42:57 +01:00
charlesgauthereau fc029af654 Merge branch 'feature' into feat/agent-update 2026-02-11 19:42:49 +01:00
Théo LAGACHE 110a69dc4b fix: react cve 19.2.4 2026-02-11 15:43:03 +01:00
Théo LAGACHE a61791fdb2 update: fix ui sidebar 2026-02-11 14:42:07 +01:00
charlesgauthereau c01440f9d4 chore(release): 1.2.5-rc.15 2026-02-11 12:34:12 +01:00
Charles GTEandGitHub 39584dd7a1 Merge pull request #69 from Portabase/feature
UI/UX enhancements
2026-02-11 12:33:16 +01:00
charlesgauthereau d9badeacba Merge branch 'feature' into feat/agent-update 2026-02-11 12:21:22 +01:00
Charles GTEandGitHub d4cde2ba9c Merge pull request #66 from Portabase/feat/refresh
Feat/refresh pagination
2026-02-11 12:18:52 +01:00
charlesgauthereau 824ef4528e fix: review and refactoring. 2026-02-11 12:18:25 +01:00
charlesgauthereau feb06a85ae Merge branch 'feature' into feat/refresh
# Conflicts:
#	app/api/agent/[agentId]/backup/route.ts
#	app/api/events/route.ts
#	src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx
#	src/components/wrappers/dashboard/database/backup/actions/backup-actions.action.ts
#	src/components/wrappers/dashboard/database/channels-policy/policy-form.tsx
#	src/lib/tasks/database/index.ts
2026-02-11 12:10:33 +01:00
Charles GTEandGitHub cfad645e33 Merge pull request #68 from Portabase/fix/large-files-storage-backend-v3
fix/large-files-storage-backend-v3
2026-02-11 12:00:57 +01:00
charlesgauthereau 675f8dec20 Merge branch 'main' into fix/large-files-storage-backend-v3
# Conflicts:
#	src/features/storages/providers/s3.ts
2026-02-11 11:54:09 +01:00
charlesgauthereau 11e85b613c chore(release): 1.2.5-rc.14 2026-02-10 20:50:33 +01:00
charlesgauthereau eb82833c7e chore(release): 1.2.5-rc.13 2026-02-10 20:47:04 +01:00
charlesgauthereau 399a3e09de chore(release): 1.2.5-rc.12 2026-02-10 20:39:55 +01:00
charlesgauthereau 1a735bda42 chore(release): 1.2.5-rc.11 2026-02-10 20:39:09 +01:00
charlesgauthereau 991196cc30 chore(release): 1.2.5-rc.10 2026-02-10 20:34:26 +01:00
charlesgauthereau 5f48903a73 chore(release): 1.2.5-rc.9 2026-02-10 20:25:54 +01:00
charlesgauthereau 5dfe1b758e fix: refactoring 2026-02-10 19:11:01 +01:00
Théo LAGACHE d24e534828 update: ui 2026-02-10 17:02:06 +01:00
Théo LAGACHE 80bdca138e add: agent update notifier, pagination 2026-02-10 15:52:08 +01:00
Théo LAGACHE 0a2ca8c0b6 add: font 2026-02-10 15:29:20 +01:00
Théo LAGACHE 921a355ae0 fix: project details table refresh 2026-02-10 11:44:45 +01:00
charlesgauthereau eff7b24b7a fix: nginx configration 2026-02-09 23:39:14 +01:00
charlesgauthereau c328620f3d fix: new storage architecture 2026-02-08 20:42:42 +01:00
Charles GTEandGitHub a49b6d6573 Merge pull request #61 from Portabase/revert-57-fix/s3
Revert "chore(release): 1.2.5-rc.1"
2026-02-04 09:57:01 +01:00
Charles GTEandGitHub baffd2efaf Revert "chore(release): 1.2.5-rc.1" 2026-02-04 09:56:34 +01:00
Charles GTEandGitHub 292693a1ed Merge pull request #57 from Portabase/fix/s3
chore(release): 1.2.5-rc.1
2026-02-04 09:56:11 +01:00
charlesgauthereau cdd01e695c fix: new storage architecture 2026-02-02 21:46:53 +01:00
charlesgauthereau 614a9e057c chore(release): 1.2.5-rc.8 2026-02-01 22:04:07 +01:00
charlesgauthereau 24d90bb750 chore(release): 1.2.5-rc.7 2026-02-01 21:14:35 +01:00
killianlarcher db1a0235ed chore(release): 1.2.5-rc.1 2026-01-31 14:18:12 +01:00
Killian LarcherandGitHub 2cd6a7cae0 Merge pull request #56 from Portabase/fix/s3
fix(storage): add test button when creating channel (not only for existing one) and add region parameter for S3.
2026-01-31 13:03:58 +01:00
killianlarcher bd80fdc960 fix(storage): add test button when creating channel (not only for existing one) and add region parameter for S3. 2026-01-31 12:41:01 +01:00
156 changed files with 5669 additions and 3054 deletions
+76 -51
View File
@@ -4,10 +4,6 @@ on:
workflow_call:
inputs:
image_name:
required: false
type: string
default: 'solucetechnologies/portabase'
image_name2:
required: false
type: string
default: 'portabase/portabase'
@@ -28,70 +24,99 @@ on:
required: true
DOCKER_PASSWORD:
required: true
DOCKER_USERNAME_2:
required: true
DOCKER_PASSWORD_2:
required: true
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
build:
name: Build and push Docker images
runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' }}
strategy:
fail-fast: false
matrix:
platform: [ linux/amd64, linux/arm64 ]
steps:
- name: Check out the repo
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set tags
id: set-tags
run: |
REF_NAME=${GITHUB_REF#refs/tags/}
TAGS="${{ inputs.image_name }}:$REF_NAME"
TAGS2="${{ inputs.image_name2 }}:$REF_NAME"
if [[ "${{ inputs.add_latest }}" == "true" ]]; then
TAGS="$TAGS,${{ inputs.image_name }}:latest"
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
- name: Login to Docker
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME }}
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
with:
context: .
file: ${{ inputs.dockerfile }}
platforms: linux/amd64,linux/arm64
platforms: ${{ matrix.platform }}
push: true
tags: ${{ steps.set-tags.outputs.tags }}
tags: ${{ steps.set-tags.outputs.image }}
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
with:
username: ${{ secrets.DOCKER_USERNAME_2 }}
password: ${{ secrets.DOCKER_PASSWORD_2 }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- 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 }}
- 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
+2 -4
View File
@@ -15,10 +15,8 @@ jobs:
with:
add_latest: false
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_USERNAME_2: ${{ secrets.DOCKER_USERNAME_2 }}
DOCKER_PASSWORD_2: ${{ secrets.DOCKER_PASSWORD_2 }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
github_release:
needs: docker_publish
+2 -4
View File
@@ -16,10 +16,8 @@ jobs:
with:
add_latest: true
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_USERNAME_2: ${{ secrets.DOCKER_USERNAME_2 }}
DOCKER_PASSWORD_2: ${{ secrets.DOCKER_PASSWORD_2 }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
github_release:
needs: docker_publish
+1
View File
@@ -40,6 +40,7 @@ public/uploads/*
!public/uploads/
private/uploads/*
private/keys/*
/.env
+1 -3
View File
@@ -2,9 +2,7 @@ title = "Custom gitleaks config"
[allowlist]
# Global allowlist patterns (wont be flagged)
regexes = [
"s3SecretAccessKey: env.S3_SECRET_KEY ?? null",
]
regexes = []
paths = [
"src/utils/init.ts"
]
+2 -2
View File
@@ -26,5 +26,5 @@ keywords:
- web-ui
- agent
license: Apache-2.0
version: 1.2.5-rc.6
date-released: "2026-02-01"
version: 1.2.7-rc.2
date-released: "2026-02-16"
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2024 Soluce Technologies
Copyright 2024 Portabase
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+3 -5
View File
@@ -7,7 +7,7 @@
<h3 align="center">Portabase</h3>
<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>
@@ -23,7 +23,6 @@
[![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/Portabase/portabase)
[![Open Source](https://img.shields.io/badge/open%20source-❤️-red)](https://github.com/Portabase/portabase)
[![NextJS][NextJS]][NextJS-url]
[![BetterAuth][BetterAuth]][BetterAuth-url]
[![Drizzle][Drizzle]][Drizzle-url]
@@ -51,14 +50,13 @@
You have 4 ways to install Portabase:
- 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)
- Development setup - [details](https://portabase.io/docs/dashboard/setup#development)
**Ensure Docker is installed on your machine before getting started.**
## Contributors
[![Contributors](https://contrib.rocks/image?repo=Portabase/portabase)](https://github.com/Portabase/portabase/graphs/contributors)
+1 -1
View File
@@ -16,7 +16,7 @@ export default async function GuardPage() {
return (
<TooltipProvider>
<CardAuth className="w-full max-w-md">
<CardAuth className="w-full">
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Two-factor verification</h1>
+13 -6
View File
@@ -2,6 +2,8 @@ import React from "react";
import {redirect} from "next/navigation";
import {currentUser} from "@/lib/auth/current-user";
import {AuthLogoSection} from "@/components/wrappers/auth/auth-logo-section";
import {env} from "@/env.mjs";
import {Heart} from "lucide-react";
export default async function Layout({children}: { children: React.ReactNode }) {
@@ -12,13 +14,18 @@ export default async function Layout({children}: { children: React.ReactNode })
}
return (
<div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8 ">
<div className="mx-auto w-full max-w-md">
<AuthLogoSection/>
<div>{children}</div>
<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">
<AuthLogoSection/>
<div className="mt-4">{children}</div>
</div>
</div>
<footer className="py-4 text-center text-xs justify-items-end text-muted-foreground">
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>
</div>
)
+4 -3
View File
@@ -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 { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-password-form/reset-password-form";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth/auth";
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 }> }) {
@@ -23,7 +24,7 @@ export default async function RoutePage(props: { searchParams: Promise<{ token:
return (
<TooltipProvider>
<Card className="w-full max-w-md shadow-lg">
<CardAuth className="w-full">
<CardHeader className="space-y-4">
<div className="space-y-1 text-center">
<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>
<ResetPasswordForm />
</CardContent>
</Card>
</CardAuth>
</TooltipProvider>
);
}
@@ -28,7 +28,7 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
}
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
return (
<Page>
<div className="justify-between gap-2 sm:flex">
@@ -1,21 +1,11 @@
import {PageParams} from "@/types/next";
import {notFound, redirect} from "next/navigation";
import {Page, PageContent, PageDescription, PageTitle} 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 {Page} from "@/features/layout/page";
import {db} from "@/db";
import {eq, and, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {getOrganizationProjectDatabases} from "@/lib/services";
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 {DatabaseContent} from "@/components/wrappers/dashboard/projects/database/database-content";
@@ -93,12 +83,6 @@ export default async function RoutePage(props: PageParams<{
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;
@@ -106,60 +90,22 @@ export default async function RoutePage(props: PageParams<{
return (
<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>
<DatabaseContent
activeMember={activeMember}
settings={settings}
database={dbItem}
isAlreadyRestore={isAlreadyRestore}
restorations={restorations}
backups={backups}
/>
</BackupModalProvider>
</PageContent>
<BackupModalProvider>
<DatabaseContent
activeMember={activeMember}
settings={settings}
database={dbItem}
isAlreadyRestore={isAlreadyRestore}
restorations={restorations}
backups={backups}
totalBackups={totalBackups}
availableBackups={availableBackups}
successRate={successRate}
organizationId={organization.id}
activeOrganizationChannels={[]}
activeOrganizationStorageChannels={[]}
/>
</BackupModalProvider>
</Page>
);
}
}
@@ -66,7 +66,7 @@ export default async function RoutePage(props: PageParams<{}>) {
organizationSlug={organization.slug}
data={projects}
cardItem={ProjectCard}
cardsPerPage={6}
cardsPerPage={9}
numberOfColumns={3}
/>
) : isMember ? (
+46 -47
View File
@@ -1,53 +1,52 @@
import fs from "node:fs";
import forge from "node-forge";
import { NextResponse } from "next/server";
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;
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 }
);
}
return handler(request, { ...context, agent });
} catch (err) {
console.error("Error in agent middleware:", err);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
};
}
export async function decryptedDump(file: File, aesKeyHex: string, ivHex: string, fileExtension: string): Promise<File> {
const privateKeyPem = fs.readFileSync("private/keys/server_private.pem", "utf8");
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()},
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
}
});
// Read encrypted file content
const encryptedBuffer = Buffer.from(await file.arrayBuffer());
const iv = forge.util.hexToBytes(ivHex);
// 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");
if (!database) {
throw NextResponse.json(
{ error: "Database associated with generatedId not found" },
{ status: 404 }
);
}
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"}
);
}
export function getFileExtension(dbType: string) {
switch (dbType) {
case "postgresql":
return ".dump";
case "mysql":
return ".sql";
default:
return ".dump";
}
}
return database;
}
+113 -129
View File
@@ -1,89 +1,63 @@
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 * 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 {decryptedDump, getFileExtension} from "./helpers";
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
import {storeBackupFiles} from "@/features/storages/helpers";
import {eventEmitter} from "@/features/shared/event";
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
import {EventKind} from "@/features/notifications/types";
export async function POST(
request: Request,
{params}: { params: Promise<{ agentId: string }> }
) {
export type BodyPost = {
method: "manual" | "automatic"
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 {
const contentType = request.headers.get("Content-Type");
if (!contentType || !contentType.includes("multipart/form-data")) {
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}
);
}
const body: BodyPost = await request.json();
const method = body.method
const database = await getDatabaseOrThrow(body.generatedId);
let backup: Backup | null | undefined = null;
if (method === "automatic") {
[backup] = await db
.insert(drizzleDb.schemas.backup)
.values({
status: 'ongoing',
databaseId: database.id,
})
.returning();
const ongoingBackup = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.status, 'ongoing'),
eq(drizzleDb.schemas.backup.databaseId, database.id),
),
});
if (!backup) {
if (!ongoingBackup) {
[backup] = await db
.insert(drizzleDb.schemas.backup)
.values({
status: 'ongoing',
databaseId: database.id,
})
.returning();
if (!backup) {
return NextResponse.json(
{error: "Unable to create an automatic backup"},
{status: 500}
);
}
} else {
return NextResponse.json(
{error: "Unable to create an automatic backup"},
{error: "A backup is already ongoing"},
{status: 500}
);
}
@@ -95,7 +69,6 @@ export async function POST(
),
});
if (!backup) {
return NextResponse.json(
{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});
}
eventEmitter.emit('modification', {update: true});
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});
await sendNotificationsBackupRestore(database, "error_backup");
return NextResponse.json(
{
message: "Backup successfully updated with status failed",
},
{status: 200}
);
}
return NextResponse.json(
{
message: "Init backup success",
backup: backup,
},
{status: 200}
);
} catch (error) {
console.error("Error in POST handler:", error);
console.error("Error in POST for INIT backup:", error);
return NextResponse.json(
{error: "Internal server error"},
{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}
);
}
});
-2
View File
@@ -19,7 +19,6 @@ export async function POST(
) {
try {
eventEmitter.emit('modification', {update: true});
const agentId = (await params).agentId
const body: BodyResultRestore = await request.json();
@@ -72,7 +71,6 @@ export async function POST(
details: "Restoration successfully updated"
}
eventEmitter.emit('modification', {update: true});
return Response.json(response, {status: 200})
} catch (error) {
+98 -53
View File
@@ -2,21 +2,24 @@ import {NextResponse} from "next/server";
import {Body} from "./route";
import {isUuidv4} from "@/utils/verify-uuid";
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 {db as dbClient} from "@/db";
import {db, db as dbClient} from "@/db";
import {and, eq, inArray} from "drizzle-orm";
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
import {withUpdatedAt} from "@/db/utils";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch";
import {Setting} from "@/db/schema/01_setting";
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 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,
dbms: database.dbms,
storages: storages,
encrypt: settings.encryption,
data: {
backup: {
action: backupAction,
@@ -25,6 +28,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
restore: {
action: restoreAction,
file: UrlBackup,
metaFile: urlMeta
},
},
});
@@ -32,12 +36,16 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
for (const db of body.databases) {
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 restoreAction: boolean = false
let urlBackup: string = ""
let urlBackup: string | null = null;
let urlMeta: string | null = null
if (!existingDatabase) {
if (!isUuidv4(db.generatedId)) {
@@ -63,8 +71,11 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
})
.returning();
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 {
@@ -78,7 +89,6 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
.returning();
const activeBackup = await dbClient.query.backup.findFirst({
where: and(
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({
where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting")),
with: {
@@ -94,7 +103,6 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
}
})
if (activeBackup && activeBackup.status == "waiting") {
backupAction = true
@@ -107,13 +115,11 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
if (restoration) {
restoreAction = true
if (!restoration.backupStorage || restoration.backupStorage.status != "success" || !restoration.backupStorage.path) {
restoreAction = false
continue;
}
const input: StorageInput = {
action: "get",
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 {
const result = await dispatchStorage(input, undefined, restoration.backupStorage.storageChannelId);
const resultMeta = await dispatchStorage(inputMeta, undefined, restoration.backupStorage.storageChannelId);
if (result.success) {
urlBackup = result.url ?? "";
urlBackup = result.url ?? null;
urlMeta = resultMeta.url ?? null
} else {
await dbClient
.update(drizzleDb.schemas.restoration)
@@ -151,53 +171,78 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
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
.update(drizzleDb.schemas.restoration)
.set({status: "ongoing"})
.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;
}
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;
}
+12 -2
View File
@@ -7,6 +7,7 @@ import {eq} from "drizzle-orm";
import {isUuidv4} from "@/utils/verify-uuid";
import {withUpdatedAt} from "@/db/utils";
import {eventEmitter} from "@/features/shared/event";
import {notFound} from "next/navigation";
export type databaseAgent = {
name: string,
@@ -26,12 +27,14 @@ export async function POST(
) {
try {
const agentId = (await params).agentId
console.log(agentId)
const body: Body = await request.json();
const lastContact = new Date();
let message: string
if (!isUuidv4(agentId)) {
message = "agentId is not a valid uuid"
console.error(message)
return NextResponse.json(
{error: "agentId is not a valid uuid"},
{status: 500}
@@ -46,7 +49,14 @@ export async function POST(
message = "Agent not found"
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
.update(drizzleDb.schemas.agent)
@@ -56,7 +66,6 @@ export async function POST(
}))
.where(eq(drizzleDb.schemas.agent.id, agentId));
eventEmitter.emit('modification', {update: true});
const response = {
agent: {
@@ -66,6 +75,7 @@ export async function POST(
databases: databasesResponse
}
return Response.json(response)
} catch (error) {
console.error('Error in POST handler:', error);
-4
View File
@@ -1,11 +1,8 @@
import {EventEmitter} from 'events';
import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers";
import {NextResponse} from "next/server";
import {eventEmitter} from "@/features/shared/event";
// export const eventEmitter = new EventEmitter();
export async function GET(request: Request) {
const session = await auth.api.getSession({
@@ -28,7 +25,6 @@ export async function GET(request: Request) {
eventEmitter.on('modification', handleModification);
// Handle client disconnect
request.signal.addEventListener('abort', () => {
console.log('Client disconnected');
controller.close();
+80
View File
@@ -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
View File
@@ -6,8 +6,11 @@
@theme inline {
--color-background: var(--background);
--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-author: var(--font-author);
--font-poppins: var(--font-poppins);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -71,7 +74,7 @@
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 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);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
@@ -82,7 +85,7 @@
--destructive: oklch(0.577 0.245 27.325);
--border: 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-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
@@ -90,12 +93,12 @@
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 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-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 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 {
@@ -105,8 +108,8 @@
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--primary: oklch(0.704 0.191 47.132);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
@@ -116,7 +119,7 @@
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--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-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
@@ -124,12 +127,12 @@
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 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-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--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 {
@@ -137,6 +140,18 @@
@apply border-border outline-ring/50;
}
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
View File
@@ -4,7 +4,7 @@ import "./globals.css";
import {Providers} from "./providers";
import {cn} from "@/lib/utils";
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";
@@ -27,7 +27,7 @@ export default async function RootLayout({
<head>
<meta name="apple-mobile-web-app-title" content={title}/>
</head>
<body className={cn(inter.className, "h-full")}>
<body className={cn(poppins.variable, author.variable, geistMono.variable, "font-sans h-full")}>
<ConsoleSilencer/>
<Providers>
{children}
-1
View File
@@ -6,7 +6,6 @@ import {ThemeProvider} from "@/features/theme/theme-provider";
import {Toaster} from "@/components/ui/sonner";
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root";
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
export type ProviderProps = PropsWithChildren<{}>;
+8 -8
View File
@@ -1,19 +1,18 @@
name: portabase-prod
services:
app:
build:
context: .
dockerfile: docker/dockerfile/Dockerfile
target: prod
# image: solucetechnologies/portabase:latest
# build:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: prod
image: portabase/portabase:latest
ports:
- '8887:80'
environment:
TZ: "Europe/Paris"
env_file:
- .env
volumes:
- portabase-data:/data
depends_on:
db:
condition: service_healthy
@@ -36,4 +35,5 @@ services:
retries: 5
volumes:
portabase-data:
postgres-data:
+14
View File
@@ -17,5 +17,19 @@ services:
timeout: 5s
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:
postgres-data:
+74 -58
View File
@@ -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
RUN apk add --update --no-cache \
build-base \
g++ \
make \
libtool \
autoconf \
automake
RUN corepack enable && corepack prepare pnpm@latest --activate
FROM build-env AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm i --frozen-lockfile
FROM build-env AS dev
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=tusd-base /usr/local/bin/tusd /usr/local/bin/tusd
WORKDIR /app
COPY . .
USER root
RUN chmod +x /app/docker/entrypoints/app-dev-entrypoint.sh
ENTRYPOINT ["sh","/app/docker/entrypoints/app-dev-entrypoint.sh"]
FROM build-env AS builder
WORKDIR /app
@@ -55,57 +82,46 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm run build
FROM base AS prod
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN corepack enable && corepack prepare pnpm@latest --activate
ENV PORT=80
ENV HOSTNAME="0.0.0.0"
ENV PGDATA=/data/postgres
ENV PRIVATE_PATH=/data/private
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
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/server ./server
COPY --from=builder /app/src ./src
COPY --from=builder /app/portabase.config.ts ./
COPY --from=builder /app/drizzle.config.ts ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --chown=nextjs:nodejs src/db ./src/db
COPY --from=builder --chown=1001:1001 /app/.next/standalone ./
COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static
COPY --chown=1001:1001 src/db ./src/db
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
RUN chmod +x /app/app-prod-entrypoint.sh
EXPOSE 80
ENV PORT=80
ENV HOSTNAME="0.0.0.0"
USER nextjs
ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"]
+72 -3
View File
@@ -7,8 +7,77 @@ else
echo "[WARN] No TZ provided, using default container timezone"
fi
pnpm run start
#node server.js
POSTGRES_BIN=$(ls -d /usr/lib/postgresql/*/bin | head -n 1)
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 "$@"
+41
View File
@@ -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;
}
}
}
+13
View File
@@ -1,6 +1,8 @@
import type {NextConfig} from "next";
import {PORTABASE_DEFAULT_SETTINGS} from "./portabase.config";
const isDev = process.env.NODE_ENV === "development";
function buildCSPHeader(): string {
const {CSP} = PORTABASE_DEFAULT_SETTINGS.SECURITY;
@@ -47,6 +49,17 @@ const nextConfig: NextConfig = {
},
proxyClientMaxBodySize: '10gb',
},
async rewrites() {
if (!isDev) return [];
return [
{
source: "/tus/:path*",
destination: "http://localhost:1080/tus/:path*",
},
];
},
async headers() {
return [
{
+81 -82
View File
@@ -1,12 +1,11 @@
{
"name": "portabase",
"version": "1.2.5-rc.6",
"version": "1.2.7-rc.2",
"private": true,
"scripts": {
"dev": "NODE_ENV=development tsx watch server/server.ts",
"dev": "next dev --turbopack -p 8887",
"build": "next build --experimental-build-mode compile",
"start": "NODE_ENV=production tsx server/server.ts",
"start": "next start",
"lint": "next lint",
"email": "email dev --dir ./src/components/emails",
"db:generate": "drizzle-kit generate",
@@ -15,52 +14,52 @@
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-accordion": "^1.2.10",
"@radix-ui/react-alert-dialog": "^1.1.13",
"@radix-ui/react-aspect-ratio": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-collapsible": "^1.1.10",
"@radix-ui/react-context-menu": "^2.2.14",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-hover-card": "^1.1.13",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-menubar": "^1.1.14",
"@radix-ui/react-navigation-menu": "^1.2.12",
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-progress": "^1.1.6",
"@radix-ui/react-radio-group": "^1.3.6",
"@radix-ui/react-scroll-area": "^1.2.8",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slider": "^1.3.4",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-toast": "^1.2.13",
"@radix-ui/react-toggle": "^1.1.8",
"@radix-ui/react-toggle-group": "^1.1.9",
"@radix-ui/react-tooltip": "^1.2.6",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-email/components": "^0.0.41",
"@t3-oss/env-nextjs": "^0.13.4",
"@tanstack/react-query": "^5.76.1",
"@t3-oss/env-nextjs": "^0.13.10",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"@types/express": "^5.0.6",
"@types/nodemailer": "^6.4.17",
"@types/nodemailer": "^6.4.22",
"@types/ws": "^8.18.1",
"@zenstackhq/runtime": "2.14.2",
"argon2": "^0.43.0",
"argon2": "^0.43.1",
"bcrypt": "^6.0.0",
"better-auth": "1.4.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dockerode": "^4.0.6",
"dotenv": "^16.5.0",
"dockerode": "^4.0.9",
"dotenv": "^16.6.1",
"drizzle-orm": "^0.43.1",
"drizzle-zod": "^0.7.1",
"embla-carousel-react": "^8.6.0",
@@ -68,65 +67,65 @@
"googleapis": "^170.1.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.553.0",
"minio": "^8.0.5",
"motion": "^12.23.24",
"minio": "^8.0.6",
"motion": "^12.34.0",
"next": "16.1.5",
"next-safe-action": "^7.10.8",
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
"node-forge": "^1.3.1",
"nodemailer": "^7.0.3",
"npm-check-updates": "^18.0.1",
"pg": "^8.16.0",
"prettier": "^3.8.0",
"react": "^19.2.0",
"node-forge": "^1.3.3",
"nodemailer": "^7.0.13",
"npm-check-updates": "^18.3.1",
"pg": "^8.18.0",
"prettier": "^3.8.1",
"react": "^19.2.4",
"react-day-picker": "9.7.0",
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-email": "^4.0.13",
"react-hook-form": "^7.56.3",
"react-dom": "^19.2.4",
"react-dropzone": "^14.4.1",
"react-email": "^4.3.2",
"react-hook-form": "^7.71.1",
"react-qr-code": "^2.0.18",
"react-resizable-panels": "^3.0.2",
"react-twc": "^1.4.2",
"react-resizable-panels": "^3.0.6",
"react-twc": "^1.5.1",
"react-use-measure": "^2.1.7",
"recharts": "^2.15.3",
"recharts": "^2.15.4",
"sharp": "^0.34.5",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"sonner": "^2.0.3",
"swiper": "^12.0.3",
"tailwind-merge": "^3.3.0",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"swiper": "^12.1.0",
"tailwind-merge": "^3.4.0",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"ws": "^8.18.2",
"zod": "^3.24.4"
"ws": "^8.19.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@iconify/react": "^6.0.0",
"@iconify/react": "^6.0.2",
"@react-email/preview-server": "4.3.2",
"@react-email/render": "^2.0.1",
"@tailwindcss/postcss": "^4.1.7",
"@react-email/render": "^2.0.4",
"@tailwindcss/postcss": "^4.1.18",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.15.18",
"@types/node-forge": "^1",
"@types/pg": "^8.15.2",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@zenstackhq/openapi": "^2.14.2",
"@zenstackhq/tanstack-query": "^2.14.2",
"@types/node": "^22.19.11",
"@types/node-forge": "^1.3.14",
"@types/pg": "^8.16.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@zenstackhq/openapi": "^2.22.1",
"@zenstackhq/tanstack-query": "^2.22.2",
"baseline-browser-mapping": "^2.9.19",
"drizzle-kit": "^0.31.1",
"esbuild": "^0.27.2",
"eslint": "^9.39.0",
"eslint-config-next": "^16.0.1",
"eslint-plugin-tailwindcss": "^3.18.0",
"framer-motion": "^12.24.7",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.7",
"tsx": "^4.19.4",
"tw-animate-css": "^1.2.9",
"typescript": "^5.8.3",
"drizzle-kit": "^0.31.9",
"esbuild": "^0.27.3",
"eslint": "^9.39.2",
"eslint-config-next": "^16.1.6",
"eslint-plugin-tailwindcss": "^3.18.2",
"framer-motion": "^12.34.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"tsx": "^4.21.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"zenstack": "2.14.2"
},
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316"
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8"
}
+923 -861
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -1,5 +1,3 @@
export interface AuthProviderConfig {
id: "google" | "github" | "credential";
isActive: boolean;
@@ -64,7 +62,7 @@ export const PORTABASE_DEFAULT_SETTINGS = {
FORM_ACTION: ["'self'"],
FRAME_ANCESTORS: ["'none'"],
BLOCK_ALL_MIXED_CONTENT: false,
UPGRADE_INSECURE_REQUESTS: true,
UPGRADE_INSECURE_REQUESTS: process.env.PROJECT_URL?.startsWith("https://") || false,
},
PERMISSIONS_POLICY: {
CAMERA: ["()"],
+3 -1
View File
@@ -53,11 +53,13 @@ function checkRouteExists(pathname: string) {
const routePatterns = [
/^\/api\/agent\/[^/]+\/status\/?$/,
/^\/api\/agent\/[^/]+\/backup\/?$/,
/^\/api\/agent\/[^/]+\/backup\/upload\/init\/?$/,
/^\/api\/agent\/[^/]+\/backup\/upload\/status\/?$/,
/^\/api\/agent\/[^/]+\/restore\/?$/,
/^\/api\/files\/images\/[^/]+\/?$/,
/^\/api\/files\/backups\/?$/,
/^\/api\/tus\/hooks\/?$/,
/^\/api\/events\/?$/,
/^\/api\/init\/?$/,
/^\/api\/config\/?$/,
/^\/api\/google\/drive\/callback\/?$/,
];
+1 -1
View File
@@ -4,7 +4,7 @@ set -e
if [ -z "$1" ]; then
echo "Usage: ./release <version>"
echo "Example: ./release v1.0.0"
echo "Example: ./release 1.0.0"
exit 1
fi
+19 -9
View File
@@ -1,6 +1,7 @@
import next from "next";
import express from "express";
import {mountApi} from "./api";
import http from "http";
const port = Number(process.env.PORT) || 8887;
const dev = process.env.NODE_ENV !== "production";
@@ -17,11 +18,17 @@ async function start() {
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();
handle(req, res).catch((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", () => {
console.log(
`NEXT APP → http://localhost:${port}`
);
console.log(
`API → http://localhost:${port}/services/v1`
);
console.log(`NEXT APP → http://localhost:${port}`);
console.log(`API → http://localhost:${port}/services/v1`);
});
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { motion, useInView, type SpringOptions, type UseInViewOptions } from 'mo
import { cn } from '@/lib/utils';
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: {
variant: {
+1 -1
View File
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
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]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
+1 -1
View File
@@ -374,7 +374,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sidebar-content"
data-sidebar="content"
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
)}
{...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>
);
};
@@ -68,7 +68,7 @@ export const ForgotPasswordForm = (props: ForgotPasswordFormProps) => {
/>
<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
</ButtonWithLoading>
<Link href="/login" className="group flex items-center text-sm hover:underline">
@@ -85,7 +85,7 @@ export const LoginForm = (props: loginFormProps) => {
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input autoComplete="email" autoFocus placeholder="exemple@portabase.io" {...field} />
<Input autoComplete="email" autoFocus placeholder="example@portabase.io" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
@@ -113,7 +113,7 @@ export const LoginForm = (props: loginFormProps) => {
</FormItem>
)}
/>
<ButtonWithLoading className="mt-2" isPending={mutation.isPending}>
<ButtonWithLoading className="mt-2 h-11" isPending={mutation.isPending}>
Login
</ButtonWithLoading>
</Form>
@@ -82,7 +82,7 @@ export const ResetPasswordForm = (props: ResetPasswordFormProps) => {
/>
<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
</ButtonWithLoading>
<Link href="/login" className="group flex items-center text-sm hover:underline">
@@ -55,7 +55,7 @@ export const RegisterForm = (props: registerFormProps) => {
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<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>
</CardHeader>
<CardContent>
@@ -88,7 +88,7 @@ export const RegisterForm = (props: registerFormProps) => {
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="exemple@portabase.io" {...field} />
<Input placeholder="example@portabase.io" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
@@ -138,7 +138,7 @@ export const RegisterForm = (props: registerFormProps) => {
</FormItem>
)}
/>
<Button type="submit" disabled={mutation.isPending}>
<Button type="submit" className="h-11" disabled={mutation.isPending}>
Sign up
</Button>
<div className="mt-4 text-center text-sm">
@@ -47,7 +47,7 @@ export const ResetPasswordForm = ({token}: ResetPasswordFormProps) => {
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<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>
</div>
</CardHeader>
@@ -1,15 +1,15 @@
"use client"
import {Button, ButtonVariantsProps} from "@/components/ui/button";
import {useState} from "react";
import {ReactNode, useState} from "react";
import {Loader2} from "lucide-react";
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
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 = {
title: string;
description: string;
button: {
button?: {
main: {
className?: string;
type?: "button" | "submit" | "reset" | undefined;
@@ -37,6 +37,11 @@ export type ButtonWithConfirmProps = {
onClick?: () => void;
};
};
children?: ReactNode;
onConfirm?: (e: React.MouseEvent) => void;
onCancel?: (e: React.MouseEvent) => void;
confirmButtonText?: string;
cancelButtonText?: string;
isPending?: boolean;
};
@@ -44,41 +49,86 @@ export type ButtonWithConfirmProps = {
export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
const [isConfirming, setIsConfirming] = useState(false);
return (
<Popover open={isConfirming} onOpenChange={!props.button.main.disabled ? setIsConfirming : undefined}>
const isLegacy = !!props.button;
const isDisabled = isLegacy ? !!props.button?.main.disabled : false;
const handleConfirm = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isLegacy) {
props.button?.confirm.onClick?.();
} else {
props.onConfirm?.(e);
}
setIsConfirming(false);
};
const handleCancel = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isLegacy) {
props.button?.cancel.onClick?.();
} else {
props.onCancel?.(e);
}
setIsConfirming(false);
};
const triggerContent = isLegacy ? (
<Button
type={props.button?.main.type}
disabled={isDisabled}
variant={props.button?.main.variant ?? "default"}
size={props.button?.main.size ?? "default"}
className={props.button?.main.className}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!isDisabled) setIsConfirming(true);
}}
>
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
{props.button?.main.icon}
{props.button?.main.text && <span>{props.button.main.text}</span>}
</Button>
) : (
<div onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsConfirming(true);
}}>
{props.children}
</div>
);
const withTooltip = isLegacy && props.button?.main.tooltipText && isDisabled ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<span
className={cn(
"inline-flex",
props.button.main.disabled ? "cursor-not-allowed" : ""
)}
role="button">
<Button
type={props.button.main.type}
disabled={!!props.button.main.disabled}
variant={props.button.main.variant ?? "default"}
size={props.button.main.size ?? "default"}
className={props.button.main.className}
onClick={() => {
if (!props.button.main.disabled) setIsConfirming(true);
}}
>
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
{props.button.main.icon}
{props.button.main.text && <span>{props.button.main.text}</span>}
</Button>
</span>
</PopoverTrigger>
<div>{triggerContent}</div>
</TooltipTrigger>
{props.button.main.tooltipText && props.button.main.disabled && (
<TooltipContent>
<p>{props.button.main.tooltipText}</p>
</TooltipContent>
)}
<TooltipContent>
<p>{props.button?.main.tooltipText}</p>
</TooltipContent>
</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="space-y-2">
<h4 className="font-medium leading-none">{props.title}</h4>
@@ -86,34 +136,26 @@ export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
</div>
<div className="grid gap-2">
<Button
onClick={() => {
if (isConfirming && props.button.confirm.onClick) {
props.button.confirm.onClick();
setIsConfirming(false);
} 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")}
onClick={handleConfirm}
variant={isLegacy ? (props.button?.confirm.variant ?? "default") : "default"}
size={isLegacy ? (props.button?.main.size ?? "default") : "default"}
className={cn(isLegacy ? props.button?.main.className : "", "w-full")}
>
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
{props.button.confirm.icon ? props.button.confirm.icon : null}
<span>{props.button.confirm.text}</span>
{isLegacy && props.button?.confirm.icon}
<span>{isLegacy ? props.button?.confirm.text : (props.confirmButtonText ?? "Confirm")}</span>
</Button>
<Button
variant={props.button.cancel.variant ? props.button.cancel.variant : "default"}
onClick={props.button.cancel.onClick ? () => props.button.cancel.onClick!() : () => setIsConfirming(false)}
className={cn(props.button.main.className, "w-full")}
size={props.button.main.size ?? "default"}
variant={isLegacy ? (props.button?.cancel.variant ?? "outline") : "outline"}
onClick={handleCancel}
className={cn(isLegacy ? props.button?.main.className : "", "w-full")}
size={isLegacy ? (props.button?.main.size ?? "default") : "default"}
>
Cancel
{isLegacy ? (props.button?.cancel.text ?? "Cancel") : (props.cancelButtonText ?? "Cancel")}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
};
};
@@ -83,7 +83,7 @@ export type ButtonWithLoadingProps = {
icon?: ReactNode;
variant?: keyof VariantButton;
className?: string;
onClick?: () => void;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
isPending?: boolean;
size?: keyof SizeButton;
} & ButtonHTMLAttributes<HTMLButtonElement>;
@@ -100,7 +100,7 @@ export const ButtonWithLoading = ({
}: ButtonWithLoadingProps) => {
return (
<Button
onClick={() => onClick?.()}
onClick={(e) => onClick?.(e)}
variant={variant}
className={className}
size={size}
@@ -26,7 +26,6 @@ export const CopyButton = (props: CopyButtonProps) => {
return (
<Button
// className={cn(buttonVariants({size: "sm"}))}
onClick={() => {
copyToClipboardWithMeta(value);
setHasCopied(true);
+35 -10
View File
@@ -1,21 +1,46 @@
import { PropsWithChildren } from "react";
import { Button } from "@/components/ui/button";
"use client";
import {PropsWithChildren, useState} from "react";
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<{
code: string;
title?: string;
className?: string;
}>;
export const CodeSnippet = (props: CodeSnippetProps) => {
const [isCopied, setIsCopied] = useState(false);
const handleCopy = async () => {
await copyToClipboardWithMeta(props.code);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
};
return (
<div className="rounded-lg border bg-background">
<div className="flex items-center justify-between border-b bg-muted px-4 py-2">
<div className="text-sm font-medium">.env</div>
<Button variant="ghost" size="icon" className="hover:bg-muted/50 text-muted-foreground"></Button>
</div>
<div className="p-4 font-mono text-sm leading-6 text-foreground truncate">
<pre className="language-javascript overflow-x-auto">
<code className="mb-6">{props.code}</code>
<div className={cn("relative group rounded-md bg-muted/50 border overflow-hidden", props.className)}>
{props.title && (
<div className="flex items-center px-4 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
{props.title}
</div>
)}
<div className="flex items-center p-4">
<pre className="flex-1 overflow-x-auto font-mono text-sm leading-relaxed">
<code className="break-all whitespace-pre-wrap">{props.code}</code>
</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>
);
+1 -1
View File
@@ -23,7 +23,7 @@ export const AgentRegistrationDialog = (props: agentRegistrationDialogProps) =>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
EDGE KEY
CONNECTION KEY
</Label>
<Input id="name" value="Pedro Duarte" readOnly className="col-span-3" />
</div>
@@ -34,7 +34,7 @@ export const GitHubStarsButtonCustom = () => {
<GithubButton
initialStars={0}
targetStars={stars}
label="Github Stars"
label=""
size="sm"
separator={true}
roundStars={true}
@@ -15,6 +15,8 @@ export type paginationNavigationProps = {
export const PaginationNavigation = (props: paginationNavigationProps) => {
const { className, totalPages, currentPage, goToPage, goToPrevPage, goToNextPage, maxVisiblePages = 3 } = props;
if (totalPages <= 1) return null;
return (
<Pagination className={cn("", className)}>
<PaginationContent>
@@ -14,7 +14,7 @@ import {
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table";
import {Input} from "@/components/ui/input";
import {ReactNode, useEffect, useState} from "react";
import {ReactNode, useMemo, useState} from "react";
import {TablePagination} from "./table-pagination";
import {Checkbox} from "@/components/ui/checkbox";
import {Button} from "@/components/ui/button";
@@ -63,35 +63,38 @@ export function DataTable<TData, TValue>({
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [rowSelection, setRowSelection] = useState({});
useEffect(() => {
setRowSelection({});
}, [data]);
if (enableSelect && data.length > 0) {
const finalColumns = useMemo(() => {
const selectColumnExists = columns.some((column) => column.id === "select");
if (!selectColumnExists) {
columns.unshift({
id: "select",
header: ({table}) => (
<Checkbox
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({row}) => <Checkbox checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"/>,
enableSorting: false,
enableHiding: false,
});
if (enableSelect && data.length > 0 && !selectColumnExists) {
return [
{
id: "select",
header: ({table}: {table: any}) => (
<Checkbox
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({row}: {row: any}) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
...columns,
];
}
}
return columns;
}, [columns, enableSelect, data.length]);
const table = useReactTable({
data,
columns,
columns: finalColumns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
@@ -99,6 +102,10 @@ export function DataTable<TData, TValue>({
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onRowSelectionChange: setRowSelection,
getRowId: (row: any) => row.id || row.uuid,
autoResetPageIndex: false,
autoResetExpanded: false,
enableRowSelection: true,
state: {
sorting,
columnFilters,
@@ -14,6 +14,10 @@ interface tablePaginationProps {
export function TablePagination(props: tablePaginationProps) {
const {className, table, maxVisiblePages = 3, pageSizeOptions = [10, 20, 30, 40, 50]} = props;
const totalPages = table.getPageCount();
if (totalPages <= 1) return null;
return (
<div
className={cn("flex gap-x-4", className)}
@@ -18,6 +18,7 @@ import {
ChannelOrganisationForm
} from "@/components/wrappers/dashboard/admin/channels/organization/channels-organization-form";
type ChannelAddModalProps = {
channel?: NotificationChannelWith | StorageChannelWith
organization?: OrganizationWithMembers;
@@ -34,6 +34,7 @@ import {
addNotificationChannelAction, updateNotificationChannelAction
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action";
type NotifierFormProps = {
onSuccessAction?: () => void;
organization?: OrganizationWithMembers;
@@ -54,7 +55,7 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
});
useEffect(() => {
form.reset(defaultValues ? {...defaultValues} : {});
form.reset(defaultValues ? {...defaultValues} : {enabled: true});
}, [defaultValues]);
const mutationAddNotificationChannel = useMutation({
@@ -118,7 +119,7 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
}}
>
{type.preview && (
{type.preview && (
<div
className="absolute bottom-0 right-0 overflow-hidden w-20 h-20 pointer-events-none">
<div
@@ -223,11 +224,11 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
<div className="flex justify-between mt-4">
<div>
{defaultValues && (
{kind == "storage" && (
<ChannelTestButton
kind={kind}
organizationId={organization?.id}
channel={defaultValues}
channel={form.getValues()}
/>
)}
</div>
@@ -1,18 +1,21 @@
"use client"
import {Button} from "@/components/ui/button";
import {NotificationChannel} from "@/db/schema/09_notification-channel";
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 {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 {cn} from "@/lib/utils";
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 {dispatchStorage} from "@/features/storages/dispatch";
type NotifierTestChannelButtonProps = {
channel: NotificationChannel | StorageChannel;
organizationId?: string;
@@ -20,8 +23,9 @@ type NotifierTestChannelButtonProps = {
}
export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestChannelButtonProps) => {
const channelText = getChannelTextBasedOnKind(kind)
const isMobile = useIsMobile()
const mutation = useMutation({
mutationFn: async () => {
if (kind === "notification") {
@@ -30,7 +34,7 @@ export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestC
message: `We are testing channel ${channel.name}`,
level: 'info',
};
const result = await dispatchNotification(payload, undefined, channel.id, organizationId);
const result = await dispatchNotification(payload, undefined, channel, organizationId);
if (result.success) {
toast.success(result.message);
@@ -41,7 +45,7 @@ export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestC
const input: StorageInput = {
action: "ping",
};
const result = await dispatchStorage(input, undefined, channel.id);
const result = await dispatchStorage(input, undefined, undefined, channel);
if (result.success) {
toast.success("Successfully connected to storage channel");
@@ -47,7 +47,7 @@ export const NotifierSmtpForm = ({form}: NotifierSmtpFormProps) => {
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="contact@exemple.com" {...field} value={field.value ?? ""}/>
<Input placeholder="contact@example.com" {...field} value={field.value ?? ""}/>
</FormControl>
<FormMessage/>
</FormItem>
@@ -77,7 +77,7 @@ export const NotifierSmtpForm = ({form}: NotifierSmtpFormProps) => {
<FormItem>
<FormLabel>From Email</FormLabel>
<FormControl>
<Input placeholder={`"Portabase" <exemple@portabase.io>`} {...field}
<Input placeholder={`"Portabase" <example@portabase.io>`} {...field}
value={field.value ?? ""}/>
</FormControl>
<FormMessage/>
@@ -136,7 +136,7 @@ export const StorageGoogleDriveForm = ({form}: StorageGoogleDriveFormProps) => {
<input type="hidden" {...form.register("config.refreshToken")} />
<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"}
</Button>
@@ -19,9 +19,22 @@ export const StorageS3Form = ({form}: StorageS3FormProps) => {
name="config.endPointUrl"
render={({field}) => (
<FormItem>
<FormLabel>Endpoind URL</FormLabel>
<FormLabel>Endpoint URL</FormLabel>
<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>
<FormMessage/>
</FormItem>
@@ -82,7 +95,7 @@ export const StorageS3Form = ({form}: StorageS3FormProps) => {
<FormField
control={form.control}
name="config.useSSL"
name="config.ssl"
render={({ field }) => (
<FormItem>
<FormLabel>Use SSL</FormLabel>
@@ -110,7 +110,7 @@ export const EmailForm = (props: EmailFormProps) => {
<FormItem>
<FormLabel>From Email *</FormLabel>
<FormControl>
<Input placeholder={"exemple@portabase.com"} {...field} />
<Input placeholder={"example@portabase.com"} {...field} />
</FormControl>
<FormDescription>{"The email from where the email will be send"}</FormDescription>
<FormMessage/>
@@ -172,7 +172,7 @@ export const EmailForm = (props: EmailFormProps) => {
<FormItem>
<FormLabel>User Email *</FormLabel>
<FormControl>
<Input placeholder={"exemple@portabase.com"} {...field} />
<Input placeholder={"example@portabase.com"} {...field} />
</FormControl>
<FormDescription>{"The email server user"}</FormDescription>
<FormMessage/>
@@ -41,7 +41,7 @@ export const SettingsTabs = ({settings, storageChannels}: SettingsTabsProps) =>
)
},
{
name: 'Default Storage',
name: 'Storage',
value: 'storage',
icon: Save,
content: (
@@ -4,7 +4,7 @@ import {Info} from "lucide-react";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {useRouter} from "next/navigation";
import {Setting} from "@/db/schema/01_setting";
import {Form, FormField, FormItem, 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 {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {useMutation} from "@tanstack/react-query";
@@ -17,6 +17,7 @@ import {
updateStorageSettingsAction
} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.action";
import {toast} from "sonner";
import {Switch} from "@/components/ui/switch";
export type SettingsStorageSectionProps = {
settings: Setting;
@@ -29,7 +30,8 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
const form = useZodForm({
schema: DefaultStorageSchema,
defaultValues: {
storageChannelId: settings.defaultStorageChannelId ?? undefined
storageChannelId: settings.defaultStorageChannelId ?? undefined,
encryption: settings.encryption ?? false
}
});
@@ -59,6 +61,7 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
</Alert>
<div className="flex flex-col h-full py-4 ">
<Form
className="space-y-4"
form={form}
onSubmit={async (values) => {
await mutation.mutateAsync(values);
@@ -68,11 +71,12 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
<FormField
control={form.control}
name="storageChannelId"
render={({ field }) => (
render={({field}) => (
<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}>
<SelectTrigger className="w-full h-full mb-0">
<SelectValue placeholder="Select a default storage channel" />
<SelectValue placeholder="Select a default storage channel"/>
</SelectTrigger>
<SelectContent>
{storageChannels.map((channel) => (
@@ -80,9 +84,10 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
<div className="flex items-center gap-2">
{getChannelIcon(channel.provider)}
<span className="font-medium">{channel.name}</span>
<span className="text-[9px] uppercase bg-secondary px-1.5 py-0.5 rounded">
{channel.provider}
</span>
<span
className="text-[9px] uppercase bg-secondary px-1.5 py-0.5 rounded">
{channel.provider}
</span>
</div>
</SelectItem>
))}
@@ -91,11 +96,29 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
</FormItem>
)}
/>
<ButtonWithLoading className="flex-shrink-0 w-full sm:w-auto" type="submit">
Confirm
</ButtonWithLoading>
<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">
Confirm
</ButtonWithLoading>
</Form>
</div>
</div>
@@ -20,11 +20,11 @@ export const updateStorageSettingsAction = userAction
const {name, data} = parsedInput;
try {
const [updatedSettings] = await db
.update(drizzleDb.schemas.setting)
.set({
defaultStorageChannelId: data.storageChannelId,
encryption: data.encryption,
})
.where(eq(drizzleDb.schemas.setting.name, name))
.returning();
@@ -1,7 +1,8 @@
import { z } from "zod";
import {z} from "zod";
export const DefaultStorageSchema = z.object({
storageChannelId: z.string(),
encryption: z.boolean(),
});
export type DefaultStorageType = z.infer<typeof DefaultStorageSchema>;
@@ -1,23 +1,145 @@
"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 {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 = {
edgeKey: string;
agentName: string;
};
export const AgentCardKey = ({edgeKey}: AgentCardKeyProps) => {
const [code, setCode] = useState<string>(`${edgeKey}`);
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);
};
return (
<>
<PasswordInput
value={code}
onChange={() => {
setCode(edgeKey);
}}
/>
<CopyButton className="mt-5" value={code}/>
</>
<div className="flex flex-col gap-4 py-2">
<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">
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"
/>
<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 {formatDateLastContact} from "@/utils/date-formatting";
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 {truncateWords} from "@/utils/text";
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 = {
data: AgentWith;
@@ -20,6 +22,8 @@ export const AgentCard = (props: agentCardProps) => {
const isMobile = useIsMobile();
const [isCopied, setIsCopied] = useState(false);
const {newRelease, isUpdateAvailable} = useAgentUpdateCheck(agent.version);
const handleCopy = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -44,9 +48,23 @@ export const AgentCard = (props: agentCardProps) => {
{isMobile ? truncateWords(agent.name, 2) : agent.name}
</h3>
{agent.version && (
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-bold tracking-wider">
v{agent.version}
</Badge>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-bold tracking-wider">
v{agent.version}
</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>
@@ -4,76 +4,111 @@ import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {Server} from "lucide-react";
import {formatDateLastContact} from "@/utils/date-formatting";
import {AgentCardKey} from "@/components/wrappers/dashboard/agent/agent-card-key/agent-card-key";
import {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 {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 = {
edgeKey: string;
agent: AgentWithDatabases
}
export const AgentContentPage = ({edgeKey, agent}: AgentContentPageProps) => {
export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPageProps) => {
useAutoRefresh({
poll: {
enabled: true,
intervalMs: 5000,
const {data} = useQuery({
queryKey: ["agent-data", initialAgent.id],
queryFn: async () => {
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 (
<>
<div className="space-y-10">
<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">
<CardTitle className="text-sm font-medium">Databases</CardTitle>
<Server className="h-4 w-4 text-muted-foreground"/>
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Databases</CardTitle>
<Server className="h-4 w-4 text-muted-foreground opacity-50"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{agent.databases.length}</div>
<p className="text-xs text-muted-foreground">Databases linked to this agent</p>
<div className="text-3xl font-bold tracking-tight">{agent.databases.length}</div>
<p className="text-xs text-muted-foreground mt-1">Linked resources</p>
</CardContent>
</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">
<CardTitle className="text-sm font-medium">Last contact</CardTitle>
<Server className="h-4 w-4 text-muted-foreground"/>
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Last contact</CardTitle>
<Server className="h-4 w-4 text-muted-foreground opacity-50"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatDateLastContact(agent.lastContact)}</div>
<p className="text-xs text-muted-foreground">Last contact with agent</p>
<div className="text-3xl font-bold tracking-tight">{formatDateLastContact(agent.lastContact)}</div>
<p className="text-xs text-muted-foreground mt-1">Status heartbeat</p>
</CardContent>
</Card>
</div>
<Card className="w-full sm:w-auto flex-1 ">
<CardHeader className="font-bold text-xl">
Edge Key
</CardHeader>
<CardContent>
<AgentCardKey
edgeKey={edgeKey}
/>
</CardContent>
</Card>
<CardsWithPagination cardsPerPage={4} numberOfColumns={2} data={agent.databases}
cardItem={AgentDatabaseCard}/>
</>
<div className="space-y-6">
<Accordion type="single" collapsible defaultValue={!agent.lastContact ? "registration" : undefined}>
<AccordionItem value="registration" className="border rounded-xl px-6 bg-card shadow-sm overflow-hidden transition-all duration-300">
<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
edgeKey={edgeKey}
agentName={agent.name}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</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";
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 { PropsWithChildren } from "react";
@@ -16,26 +16,29 @@ export type agentRegistrationDialogProps = PropsWithChildren<{
export function AgentModalKey(props: agentRegistrationDialogProps) {
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 (
<Dialog>
<DialogTrigger asChild>{props.children}</DialogTrigger>
<DialogContent className="sm:max-w-[425px] w-full">
<DialogHeader>
<DialogTitle>Agent Edge Key</DialogTitle>
<DialogTitle>Agent Connection</DialogTitle>
</DialogHeader>
<div className="sm:max-w-[375px] w-full">
<div className="sm:max-w-[375px] w-full space-y-4">
<CodeSnippet
code={code}
// className="w-full overflow-x-auto break-words"
title="Installation Command"
code={command}
/>
<CodeSnippet
title="Agent Key (Manual)"
code={edge_key}
/>
</div>
<DialogFooter>
<div className="flex items-center justify-between w-full">
<CopyButton value={code} />
<Button type="submit">Save changes</Button>
</div>
<DialogClose asChild>
<Button type="button" variant="outline">Close</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1,8 +1,7 @@
"use client";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {backupButtonAction} from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
@@ -16,6 +15,7 @@ export type BackupButtonProps = {
};
export const BackupButton = (props: BackupButtonProps) => {
const queryClient = useQueryClient();
const router = useRouter();
const isMobile = useIsMobile()
@@ -24,6 +24,7 @@ export const BackupButton = (props: BackupButtonProps) => {
const backup = await backupButtonAction(databaseId);
if (backup?.data?.success) {
toast.success(backup.data.actionSuccess?.message || "Backup created successfully!");
queryClient.invalidateQueries({queryKey: ["database-data", props.databaseId]});
router.refresh();
} else {
toast.error(backup?.serverError || "Failed to create backup.");
@@ -1,26 +1,36 @@
"use client";
import {PropsWithChildren, ReactNode, useState} from "react";
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 {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";
export type LoggedInDropdownProps = PropsWithChildren<{
user: User;
user: UserType;
sessions: Session[];
currentSession: Session;
accounts: Account[];
children: ReactNode;
providers: AuthProviderConfig[]
}>;
export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers }: LoggedInDropdownProps) => {
const router = useRouter();
const [isModalOpen, setIsModalOpen] = useState(false);
return (
@@ -34,34 +44,50 @@ export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, chi
onOpenChange={setIsModalOpen}
providers={providers}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="min-w-[var(--radix-popper-anchor-width)]">
<DropdownMenuItem onClick={() => setIsModalOpen(!isModalOpen)}>
<div className="flex justify-start items-center gap-2">
<CircleUser size={16} />
<span>Account</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
await signOut({
fetchOptions: {
onSuccess: () => {
router.push("/login");
},
},
});
}}
>
<div className="flex justify-start items-center gap-2">
<LogOut size={16} className="text-red-500" />
<span className="text-red-500">Logout</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none p-1"
align="start"
side="top"
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>
</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 () => {
await signOut({
fetchOptions: {
onSuccess: () => {
router.push("/login");
},
},
});
}}
>
<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={18} className="text-red-500" />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium leading-none">Logout</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
};
@@ -42,4 +42,4 @@ export function AppSidebar() {
<SideBarFooterCredit/>
</Sidebar>
);
}
}
@@ -7,20 +7,17 @@ import {
Layers,
ChartArea,
ShieldHalf,
Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse
Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse, BookOpen
} from "lucide-react";
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 = () => {
const BASE_URL = `/dashboard`;
const {data: activeOrganization} = authClient.useActiveOrganization();
const {data: organizations} = authClient.useListOrganizations();
const {data: session, isPending, error} = authClient.useSession();
const member = authClient.useActiveMember();
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}/>;
};
};
@@ -16,4 +16,4 @@ export const SideBarFooterCredit = (props: SideBarFooterCreditProps) => {
)}
</>
);
};
};
@@ -33,7 +33,7 @@ export function DatabaseActionsCell({backup, activeMember, isAlreadyRestore}: Da
<div className={cn("flex items-center space-x-2")}>
<DropdownMenu>
<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>
<MoreHorizontal className="w-4 h-4"/>
</Button>
@@ -1,7 +1,7 @@
"use client"
import {Backup, BackupWith, Restoration} from "@/db/schema/07_database";
import React from "react";
import {Swiper, SwiperSlide} from "swiper/react";
//@ts-ignore
import "swiper/css";
import "swiper/css/pagination";
import {Pagination, Mousewheel} from "swiper/modules";
@@ -12,14 +12,13 @@ import {
BackupActionsType
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.schema";
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 {TooltipProvider} from "@/components/ui/tooltip";
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 {getStatusColor, getStatusIcon} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
import {useRouter} from "next/navigation";
import {
createRestorationBackupAction, deleteBackupAction, deleteBackupStorageAction,
downloadBackupAction
@@ -29,8 +28,7 @@ import {SafeActionResult} from "next-safe-action";
import {ServerActionResult} from "@/types/action-type";
import {ZodString} from "zod";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
import {AlertCircleIcon, Trash2} from "lucide-react";
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
import {AlertCircleIcon} from "lucide-react";
type BackupActionsFormProps = {
backup: BackupWith;
@@ -40,8 +38,9 @@ type BackupActionsFormProps = {
export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
const filteredBackupStorages = backup.storages?.filter((storage) => storage.deletedAt === null) ?? []
const isMobile = useIsMobile();
const {closeModal} = useBackupModal();
const queryClient = useQueryClient();
const router = useRouter();
const form = useZodForm({
schema: BackupActionsSchema,
@@ -74,8 +73,9 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
router.refresh();
if (action === "download") {
const url = inner.value
if (typeof url === "string") {
window.open(url, "_self");
@@ -91,6 +91,8 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
} else {
if (action === "delete") {
toast.success("Backup deleted successfully.")
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
router.refresh();
closeModal()
} else {
toast.error(inner?.actionError?.message ?? "An error occurred.");
@@ -111,6 +113,8 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
router.refresh();
closeModal()
} else {
toast.error(inner?.actionError?.message);
@@ -130,10 +134,7 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
}}
>
{filteredBackupStorages.length > 0 ?
<FormField
control={form.control}
name="backupStorageId"
@@ -176,24 +177,26 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0 flex flex-col gap-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{getChannelIcon(storage.storageChannel?.provider || "")}
<h3 className="font-medium text-foreground">
{isMobile ? truncateWords(storage?.storageChannel?.name ?? "", 2) : storage.storageChannel?.name}
</h3>
<Badge variant="secondary"
className="text-xs font-mono">
{storage.storageChannel?.provider}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0">
{getChannelIcon(storage.storageChannel?.provider || "")}
</div>
<h3 className="font-medium text-foreground truncate">
{storage.storageChannel?.name}
</h3>
<Badge variant="secondary"
className="text-xs font-mono shrink-0">
{storage.storageChannel?.provider}
</Badge>
</div>
<Badge variant="outline"
className={`gap-1.5 shrink-0 ${getStatusColor(storage.status)}`}>
{getStatusIcon(storage.status === "success")}
<span
className="capitalize">{storage.status.toUpperCase()}</span>
</Badge>
</div>
<Badge variant="outline"
className={`gap-1.5 ${getStatusColor(storage.status)}`}>
{getStatusIcon(storage.status === "success")}
<span
className="capitalize">{storage.status.toUpperCase()}</span>
</Badge>
</div>
</div>
</div>
</div>
@@ -221,45 +224,15 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
<div className="flex flex-row items-center gap-x-4 w-full">
{action === "delete" && (
// <ButtonWithLoading
// type="button"
// variant="destructive"
// 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",
},
}}
<ButtonWithLoading
type="button"
variant="destructive"
onClick={() => mutationDeleteEntireBackup.mutateAsync()}
isPending={mutationDeleteEntireBackup.isPending}
/>
disabled={mutationDeleteEntireBackup.isPending}
>
Delete entire backup
</ButtonWithLoading>
)}
{filteredBackupStorages.length > 0 && (
@@ -58,9 +58,7 @@ export const downloadBackupAction = userAction.schema(
};
const result = await dispatchStorage(input, undefined, backupStorage.storageChannelId);
console.log(result);
return {
success: result.success,
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 {BackupWith} from "@/db/schema/07_database";
import {useRouter} from "next/navigation";
export type DatabaseActionKind = "restore" | "download" | "delete";
@@ -32,7 +31,6 @@ const BackupModalContext = createContext<BackupModalContextType | undefined>(und
export const BackupModalProvider = ({children}: { children: ReactNode }) => {
const [open, setOpen] = useState(false);
const router = useRouter();
const [action, setAction] = useState<DatabaseActionKind | null>(null);
const [backup, setBackup] = useState<BackupWith | null>(null);
@@ -46,7 +44,6 @@ export const BackupModalProvider = ({children}: { children: ReactNode }) => {
setOpen(false);
setAction(null);
setBackup(null);
router.refresh()
};
return (
@@ -8,13 +8,13 @@ import {Button} from "@/components/ui/button";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select";
import {useMutation} from "@tanstack/react-query";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {Switch} from "@/components/ui/switch";
import {Card} from "@/components/ui/card";
import Link from "next/link";
import {useIsMobile} from "@/hooks/use-mobile";
import {useRouter} from "next/navigation";
import {
ChannelKind,
getChannelIcon,
@@ -48,6 +48,7 @@ export const ChannelPoliciesForm = ({
onSuccess,
kind
}: ChannelPoliciesFormProps) => {
const queryClient = useQueryClient();
const router = useRouter();
const isMobile = useIsMobile();
const channelText = getChannelTextBasedOnKind(kind);
@@ -104,10 +105,6 @@ export const ChannelPoliciesForm = ({
return existing &&
(existing.eventKinds !== policy.eventKinds || existing.enabled !== policy.enabled);
});
const promises = kind === "notification"
? [
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");
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"); },
});
@@ -37,7 +37,6 @@ export const ChannelPoliciesModal = ({icon, kind, database, channels, organizati
const channelsIds = channelsFiltered
.map(channel => channel.id);
console.log(channelsIds);
const activeAlertPolicies = database.alertPolicies?.filter((policy) => channelsIds.includes(policy.notificationChannelId));
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 { Separator } from "@/components/ui/separator";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { updateDatabaseBackupPolicyAction } from "@/components/wrappers/dashboard/database/cron-button/cron.action";
import {Database} from "@/db/schema/07_database";
@@ -18,6 +18,7 @@ export type CronButtonProps = {
};
export const CronButton = (props: CronButtonProps) => {
const queryClient = useQueryClient();
const router = useRouter();
const [isSwitched, setIsSwitched] = useState(props.database.backupPolicy !== null);
const [open, setOpen] = useState(false);
@@ -27,6 +28,7 @@ export const CronButton = (props: CronButtonProps) => {
updateDatabaseBackupPolicyAction({ databaseId: props.database.id, backupPolicy: value }),
onSuccess: () => {
toast.success(`Method updated successfully.`);
queryClient.invalidateQueries({ queryKey: ["database-data", props.database.id] });
router.refresh();
},
onError: () => {
@@ -1,8 +1,8 @@
import {AdvancedCronSelect} from "./advanced-cron-select";
import {updateDatabaseBackupPolicyAction} from "@/components/wrappers/dashboard/database/cron-button/cron.action";
import {useMutation} from "@tanstack/react-query";
import {useState} from "react";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {useState} from "react";
import {toast} from "sonner";
import {Button} from "@/components/ui/button";
import {Separator} from "@/components/ui/separator";
@@ -15,6 +15,7 @@ export type CronInputProps = {
export const CronInput = ({database, onSuccess}: CronInputProps) => {
const [cron, setCron] = useState<string>(database.backupPolicy ?? "* * * * *");
const queryClient = useQueryClient();
const router = useRouter();
const updateBackupPolicy = useMutation({
@@ -22,6 +23,7 @@ export const CronInput = ({database, onSuccess}: CronInputProps) => {
onSuccess: () => {
toast.success(`Cron updated successfully.`);
onSuccess?.()
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
router.refresh();
},
onError: () => {
@@ -1,10 +1,10 @@
"use client";
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 {Loader2} from "lucide-react";
import {useRouter} from "next/navigation";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {toast} from "sonner";
import {uploadBackupAction} from "@/components/wrappers/dashboard/database/import/upload-backup.action";
@@ -16,6 +16,7 @@ type UploadRetentionZoneProps = {
};
export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZoneProps) => {
const queryClient = useQueryClient();
const router = useRouter();
const [isProcessing, setIsProcessing] = useState(false);
@@ -37,6 +38,7 @@ export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZ
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
onSuccessAction?.()
queryClient.invalidateQueries({queryKey: ["database-data", databaseId]});
router.refresh();
} else {
toast.error(inner?.actionError?.message);
@@ -45,7 +47,7 @@ export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZ
console.error(err);
toast.error("An error occurred while upload in the backup");
} finally {
router.refresh();
queryClient.invalidateQueries({queryKey: ["database-data", databaseId]});
setIsProcessing(false);
}
},
@@ -66,7 +68,7 @@ export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZ
) : (
<DropZoneFile
accept={acceptDbImportFiles}
maxSize={500 * 1024 * 1024}
maxSize={2 * 1024 * 1024 * 1024}
maxFiles={1}
description="Import database backup"
fileKind="Database file (.sql, .dump)"
@@ -4,13 +4,11 @@ import {ServerActionResult} from "@/types/action-type";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {Backup} from "@/db/schema/07_database";
import {getFileExtension} from "../../../../../../app/api/agent/[agentId]/backup/helpers";
import {v4 as uuidv4} from "uuid";
import {eq} from "drizzle-orm";
import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action";
import {z} from "zod";
import {env} from "@/env.mjs";
import {storeBackupFiles} from "@/features/storages/helpers";
import {getFileExtension} from "@/utils/common";
export const uploadBackupAction = userAction
@@ -48,18 +46,6 @@ export const uploadBackupAction = userAction
const fileName = `${uuid}${fileExtension}`;
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
.insert(drizzleDb.schemas.backup)
@@ -70,30 +56,8 @@ export const uploadBackupAction = userAction
})
.returning();
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 {
success: true,
value: backup,
@@ -10,11 +10,11 @@ import {
useZodForm
} from "@/components/ui/form";
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 {DatabaseWith, RetentionPolicy} from "@/db/schema/07_database";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {RadioGroup, RadioGroupItem} from "@/components/ui/radio-group";
import {Badge} from "@/components/ui/badge";
import {Separator} from "@/components/ui/separator";
@@ -28,6 +28,7 @@ export type BackupRetentionSettingsFormProps = {
};
export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRetentionSettingsFormProps) => {
const queryClient = useQueryClient();
const router = useRouter();
const defaultValuesFormatted: RetentionSettings = {
@@ -55,6 +56,7 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet
}),
onSuccess: () => {
toast.success("Retention policy updated successfully.");
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
router.refresh();
},
onError: () => {
@@ -49,7 +49,7 @@ export function BackupRetentionSettings({database}: BackupRetentionSettingsProps
yearly: 3,
},
})
const router = useRouter()
const queryClient = useQueryClient()
const updateRetentionPolicy = useMutation({
mutationFn: async (payload: RetentionSettings) => await updateOrCreateBackupRetentionPolicyAction({
@@ -58,7 +58,7 @@ export function BackupRetentionSettings({database}: BackupRetentionSettingsProps
}),
onSuccess: () => {
toast.success("Retention policy updated successfully.")
router.refresh()
queryClient.invalidateQueries({queryKey: ["database-data", database.id]})
},
onError: () => {
toast.error("An error occurred while updating retention policy.")
@@ -18,7 +18,7 @@ import {Skeleton} from "@/components/ui/skeleton"
export function OrganizationCombobox() {
const router = useRouter()
const {isMobile, state} = useSidebar()
const {state} = useSidebar()
const {data: organizations, isPending: isPendingList, refetch} = authClient.useListOrganizations()
const {
data: activeOrganization,
@@ -75,7 +75,7 @@ export function OrganizationCombobox() {
>
<div className={cn(
"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"
)}>
{activeOrganization?.logo ? (
@@ -95,7 +95,7 @@ export function OrganizationCombobox() {
</SidebarMenuButton>
</DropdownMenuTrigger>
<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"
side="bottom"
sideOffset={4}
@@ -109,33 +109,33 @@ export function OrganizationCombobox() {
className={cn(
"group gap-2 p-1 cursor-pointer rounded-lg mb-1 last:mb-0 transition-colors",
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"
)}>
<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" : "",
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 ? (
<img src={org.logo} alt={org.name} className="size-9 rounded-md object-cover"/>
) : (
<Building2 className={cn(
"size-5",
isActive ? "text-white" : "text-muted-foreground"
isActive ? "text-primary-foreground" : "text-muted-foreground"
)}/>
)}
</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(
"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>
</div>
{isActive && (
<div
className="ml-auto flex size-5 items-center justify-center rounded-full bg-orange-500 shadow-sm">
<Check className="size-3 text-white" strokeWidth={3}/>
className="ml-auto flex size-5 items-center justify-center rounded-full bg-primary shadow-sm">
<Check className="size-3 text-primary-foreground" strokeWidth={3}/>
</div>
)}
</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">
<Plus className="size-4"/>
</div>
<div className="font-medium">Create new organization</div>
<div className="font-medium">Create organization</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -145,7 +145,7 @@ export default function TwoFactorForm({ onSuccess, onSuccessData }: TwoFactorFor
/>
<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" />}
Verify
</Button>
@@ -1,6 +1,5 @@
"use client";
import {useEffect} from "react";
import {Button} from "@/components/ui/button";
import {Input} from "@/components/ui/input";
import {AlertCircle, Loader2} from "lucide-react";
@@ -22,18 +21,6 @@ export function ProfileAccount({user}: ProfileAccountProps) {
const router = useRouter();
useEffect(() => {
let interval: NodeJS.Timeout;
interval = setInterval(async () => {
router.refresh();
}, 5000);
return () => {
if (interval) clearInterval(interval);
};
});
const emailForm = useZodForm({
schema: EmailSchema,
defaultValues: {
@@ -21,7 +21,7 @@ export function ProfileAppearance() {
}
function ThemeSelector() {
const {theme, setTheme} = useTheme();
const {theme} = useTheme();
return (
@@ -39,7 +39,6 @@ function ThemeSelector() {
isActive ? "border-primary bg-primary/5" : "border-muted/40"
)}
onClick={async () => {
// setTheme(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 {Backup, BackupWith, DatabaseWith} from "@/db/schema/07_database";
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 {useRouter} from "next/navigation";
import {MemberWithUser} from "@/db/schema/03_organization";
import {deleteBackupAction} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action";
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
type DatabaseBackupListProps = {
@@ -32,7 +32,12 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
]
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(() => {
if (!props.backups) return [];
@@ -64,19 +69,12 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
mutationFn: async (backups: Backup[]) => {
const results = await Promise.all(
backups.map(async (backup) => {
if (backup.deletedAt == null) {
if (backup.deletedAt == null || backup.status == "ongoing") {
const backupDeleted = await deleteBackupAction({
databaseId: backup.databaseId,
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 {
success: backupDeleted?.data?.success,
message: backupDeleted?.data?.success
@@ -98,7 +96,7 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
toast.error(result.message);
}
});
router.refresh();
queryClient.invalidateQueries({queryKey: ["database-data", props.database.id]});
},
});
@@ -107,7 +105,7 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
return (
<DataTable
enableSelect={!isMember}
columns={backupColumns(props.isAlreadyRestore, props.settings, props.database, props.activeMember)}
columns={columns}
data={filteredBackups}
enablePagination
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 gap-2">
{!isMember && (
<DropdownMenu>
<DropdownMenu open={isActionsOpen} onOpenChange={setIsActionsOpen}>
<DropdownMenuTrigger asChild>
<ButtonWithLoading
variant="outline"
onClick={() => {
onClick={(e) => {
e.stopPropagation();
setIsActionsOpen(true);
}}
disabled={rows.length === 0 || mutationDeleteBackups.isPending}
icon={<MoreHorizontal/>}
isPending={mutationDeleteBackups.isPending}
size="sm"
type="button"
>Actions</ButtonWithLoading>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={async () => {
await mutationDeleteBackups.mutateAsync(rows)
<ButtonWithConfirm
onConfirm={() => {
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"/>
Delete Selected
</DropdownMenuItem>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Trash2 className="me-2 h-4 w-4 text-red-600"/>
<span className="text-red-600">Delete Selected</span>
</DropdownMenuItem>
</ButtonWithConfirm>
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -5,6 +5,17 @@ import {Setting} from "@/db/schema/01_setting";
import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database";
import {MemberWithUser} from "@/db/schema/03_organization";
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 = {
@@ -13,27 +24,121 @@ export type DatabaseContentProps = {
restorations: Restoration[],
isAlreadyRestore: boolean,
database: DatabaseWith,
activeMember: MemberWithUser
activeMember: MemberWithUser,
totalBackups: number,
availableBackups: number,
successRate: number | null,
organizationId: string,
activeOrganizationChannels: any[],
activeOrganizationStorageChannels: any[]
}
export const DatabaseContent = ({
settings,
backups,
activeMember,
isAlreadyRestore,
restorations,
database
}: DatabaseContentProps) => {
export const DatabaseContent = (props: DatabaseContentProps) => {
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 (
<>
<DatabaseBackupActionsModal/>
<DatabaseTabs activeMember={activeMember} settings={settings} database={database}
isAlreadyRestore={isAlreadyRestore}
backups={backups}
restorations={restorations}/>
<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/>
<DatabaseTabs
activeMember={props.activeMember}
settings={props.settings}
database={database}
isAlreadyRestore={isAlreadyRestore}
backups={backups}
restorations={restorations}
/>
</PageContent>
</>
)
}
@@ -11,7 +11,6 @@ export type DatabaseKpiPro = {
totalBackups: number;
availableBackups: number;
};
export const DatabaseKpi = (props: DatabaseKpiPro) => {
return (
<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>
<CardContent>
<div className="text-2xl font-bold">
{props.successRate ? `${props.successRate.toFixed(0)} %` : "Unavailable"}
{typeof props.successRate === 'number' ? `${props.successRate.toFixed(0)} %` : "Unavailable"}
</div>
<p className="text-xs text-muted-foreground">Backup success rate</p>
</CardContent>
@@ -5,21 +5,28 @@ import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {MoreHorizontal, Trash2} from "lucide-react";
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 {toast} from "sonner";
import {useRouter} from "next/navigation";
import {MemberWithUser} from "@/db/schema/03_organization";
import {useMemo, useState} from "react";
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
type DatabaseRestoreListProps = {
isAlreadyRestore: boolean;
restorations: Restoration[];
activeMember: MemberWithUser
activeMember: MemberWithUser;
databaseId: string;
}
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({
mutationFn: async (restorations: Restoration[]) => {
@@ -46,7 +53,7 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
toast.error(result.message);
}
});
router.refresh();
queryClient.invalidateQueries({queryKey: ["database-data", props.databaseId]});
},
});
const isMember = props.activeMember.role === "member";
@@ -55,35 +62,48 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
return (
<DataTable
enableSelect={!isMember}
columns={restoreColumns(props.isAlreadyRestore, props.activeMember)}
columns={columns}
data={props.restorations}
enablePagination
selectedActions={(rows) => (
<>
{!isMember && (
<DropdownMenu>
<DropdownMenu open={isActionsOpen} onOpenChange={setIsActionsOpen}>
<DropdownMenuTrigger asChild>
<ButtonWithLoading
variant="outline"
onClick={() => {
onClick={(e) => {
e.stopPropagation();
setIsActionsOpen(true);
}}
disabled={rows.length === 0 || mutationDeleteRestorations.isPending}
icon={<MoreHorizontal/>}
isPending={mutationDeleteRestorations.isPending}
size="sm"
type="button"
>Actions</ButtonWithLoading>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={async () => {
await mutationDeleteRestorations.mutateAsync(rows)
<ButtonWithConfirm
onConfirm={() => {
mutationDeleteRestorations.mutate(rows)
setIsActionsOpen(false);
}}
disabled={props.isAlreadyRestore}
className="text-red-600 focus:text-red-700"
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"
>
<Trash2 className="w-4 h-4 mr-2"/>
Delete Selected
</DropdownMenuItem>
<DropdownMenuItem
disabled={props.isAlreadyRestore}
onSelect={(e) => e.preventDefault()}
className="text-red-600 focus:text-red-700"
>
<Trash2 className="w-4 h-4 mr-2"/>
Delete Selected
</DropdownMenuItem>
</ButtonWithConfirm>
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -3,7 +3,6 @@
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {useEffect, useState} from "react";
import {useRouter, useSearchParams} from "next/navigation";
import {eventUpdate} from "@/types/events";
import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database";
import {Setting} from "@/db/schema/01_setting";
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");
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(() => {
const newTab = searchParams.get("tab") ?? "backup";
setTab(newTab);
@@ -69,6 +53,7 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => {
isAlreadyRestore={props.isAlreadyRestore}
restorations={props.restorations}
activeMember={props.activeMember}
databaseId={props.database.id}
/>
</TabsContent>
</Tabs>
@@ -18,11 +18,11 @@ export const ProjectCard = (props: projectCardProps) => {
return (
<Link
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-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" />
</div>
<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 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}
</h3>
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
@@ -40,8 +40,8 @@ export const ProjectCard = (props: projectCardProps) => {
</div>
<div className="mt-4 flex items-center justify-between pt-3 border-t border-border/50">
<span className="text-[10px] font-bold uppercase tracking-widest text-primary opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-x-[-10px] group-hover:translate-x-0">View Project</span>
<div className="flex items-center gap-1 text-muted-foreground group-hover:text-primary transition-colors">
<span className="text-[10px] font-bold uppercase tracking-widest 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-orange-500 transition-colors">
<span className="text-[10px] font-bold uppercase tracking-widest group-hover:hidden">Details</span>
<ChevronRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform" />
</div>

Some files were not shown because too many files have changed in this diff Show More