Compare commits

...
48 Commits
Author SHA1 Message Date
github-actions[bot] adda02e91f chore: release 1.22.1 2026-07-05 17:36:01 +00:00
Charles GTEandGitHub 4fd3125bf9 Merge pull request #361 from Portabase/fix/delete-org
fix: org deletion
2026-07-05 19:35:14 +02:00
charles-gauthereau 2ef7f7929d fix: org deletion 2026-07-05 19:15:01 +02:00
github-actions[bot] 666d677349 chore: release 1.22.0 2026-07-05 16:50:54 +00:00
Charles GTEandGitHub 1afc6b8c62 Merge pull request #359 from Portabase/dev
Dev
2026-07-05 18:50:16 +02:00
Charles GTEandGitHub 9f15b49345 Merge pull request #360 from Portabase/fix/remove-database-link-to-agent
fix: remove-database-link-to-agent
2026-07-05 18:32:58 +02:00
charles-gauthereau 6d19fb8833 fix: database-delete.action.ts 2026-07-05 18:31:52 +02:00
charles-gauthereau df692c4729 fix(database): restrict db delete to agent-owning org (system agents admin-only)
- UI: delete button gated by canDeleteDatabases prop threaded from each page
  (org page: only org-created agents; admin page: system agents)
- authz: org path requires agent.organizationId === active org; drop join-based
  attribution so a system agent merely linked to an org is admin-delete-only
2026-07-05 18:31:52 +02:00
charles-gauthereau cd72cfbff4 fix(database): authorize soft-delete by agent org + add onError + guard bulk-restore lists 2026-07-05 18:31:52 +02:00
charles-gauthereau 20636bf6f2 feat(database): reject soft-deleted databases in single-row lookups 2026-07-05 18:31:52 +02:00
charles-gauthereau 8f2d6f0b01 feat(database): exclude soft-deleted databases from all list queries 2026-07-05 18:31:52 +02:00
charles-gauthereau 64b3bf1b16 feat(agents): show database delete button in card footer on agent page 2026-07-05 18:31:52 +02:00
charles-gauthereau dc4190aa1b fix(agents): add database delete button with confirm popover 2026-07-05 18:31:52 +02:00
charles-gauthereau 6efcb55638 fix(database): add soft-delete server action clearing links and policies 2026-07-05 18:31:45 +02:00
charles-gauthereau 6b89cdfa1d fix(db): make databases.agent_id and agent_database_id nullable for soft-delete 2026-07-05 18:11:32 +02:00
charles-gauthereau 693ef6ec3e chore: remove unused Link import and stray whitespace 2026-07-05 17:06:42 +02:00
Charles GTEandGitHub de33dea50d Merge pull request #358 from Portabase/fix/mass-backup-restore-button-on-project-page
fix: mass-backup-restore-button-on-project-page
2026-07-05 16:34:23 +02:00
charles-gauthereau d5e453c390 fix: docker volume upload 2026-07-05 16:33:28 +02:00
Charles GTE ff65590c97 fix: block if already ongoing or waiting 2026-07-05 14:06:40 +02:00
Charles GTE 503d5469d4 fix: bulk restore/backup 2026-07-05 13:50:33 +02:00
Charles GTE 96c51a08c6 fix(projects): enable bulk backup/restore selection on project page 2026-07-05 13:50:33 +02:00
Charles GTE fe1d077754 fix(projects): selection wrapper wiring bulk backup/restore with toasts 2026-07-05 13:50:33 +02:00
Charles GTE f2acc103a7 fix(projects): bulk restore preview + type-to-confirm modal 2026-07-05 13:50:33 +02:00
Charles GTE c073a30d20 fix(projects): floating bulk action bar 2026-07-05 13:50:33 +02:00
Charles GTE bdc863de1f fix(backup): bulkBackupAction to queue backups for many databases 2026-07-05 13:50:33 +02:00
Charles GTE 97e2c21d52 fix(restore): bulk restore preview + queue actions with latest-backup resolver 2026-07-05 13:50:33 +02:00
Charles GTE 60f57a734d fix(db-card): optional selection checkbox and selected state 2026-07-05 13:50:33 +02:00
github-actions[bot] 6952ac5f48 chore: release 1.21.3 2026-07-04 12:44:37 +00:00
45acc781f2 fix: onboarding (#354)
* fix

* fix: README.md

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
Co-authored-by: Charles GTE <charlesgte31@gmail.com>
2026-07-04 14:43:58 +02:00
github-actions[bot] 9c2359e87b chore: release 1.21.2 2026-07-04 10:25:02 +00:00
Charles GTEandGitHub 8dcb406790 fix: security (#355) 2026-07-04 12:24:23 +02:00
Charles GTEandGitHub a3d0a6e1df fix: update README.md (#353) 2026-07-03 11:42:02 +02:00
github-actions[bot] 8edf0ca742 chore: release 1.21.1 2026-07-03 09:16:44 +00:00
568d9612c5 fix: stale-ongoing-backups (#352)
* fix: add STALE_BACKUP_THRESHOLD_HOURS env (#351)

* fix: fail stale ongoing backups and notify (#351)

* fix: prefer waiting backup over stale ongoing in dispatch (#351)

* fix: prefer waiting over ongoing by status priority in dispatch (#351)

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-07-03 11:15:57 +02:00
github-actions[bot] 6548945b2f chore: release 1.21.0 2026-07-03 06:15:32 +00:00
Charles GTEandGitHub 4b1d0aba74 Merge pull request #350 from Portabase/feat/backup-restore-docker-volume
feat: docker-volume provider
2026-07-03 08:14:51 +02:00
charles-gauthereau c726346258 feat: docker-volume provider 2026-07-02 21:55:19 +02:00
github-actions[bot] 0677f14ac5 chore: release 1.20.7 2026-07-01 06:11:01 +00:00
Charles GTEandGitHub d346f5cdc4 Merge pull request #349 from Portabase/fix/docker-image-weight
fix: docker-image-weight
2026-07-01 08:10:22 +02:00
charles-gauthereau 1ce08448ec fix: Dockerfile 2026-06-30 16:56:28 +02:00
charles-gauthereau 77fb1095f1 fix: removed unused packages 2026-06-30 16:24:06 +02:00
29dd597c7a fix: s3-ssl-error [skip-release] (#348)
* fix: rustfs test instance

* fix: s3.form.tsx

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-06-30 15:32:06 +02:00
github-actions[bot] 7943f3fd4b chore: release 1.20.6 2026-06-30 07:33:58 +00:00
a63c29b943 fix: docker.yml (#346)
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-06-30 09:32:58 +02:00
github-actions[bot] f4d72090a3 chore: release 1.20.5 2026-06-30 07:14:59 +00:00
8b379067a1 fix: readme (#345)
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-06-30 09:13:56 +02:00
github-actions[bot] 896b65f2a5 chore: release 1.20.4 2026-06-30 06:18:38 +00:00
79d725cd12 fix: uuid v7 (#344)
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-06-30 08:17:46 +02:00
61 changed files with 7298 additions and 2678 deletions
+1 -1
View File
@@ -73,7 +73,7 @@ jobs:
tags: ${{ steps.prep.outputs.image }}
target: ${{ inputs.target }}
cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }},ignore-error=true
- name: Save image name for manifest
run: echo "${{ steps.prep.outputs.image }}" > image.txt
+1 -1
View File
@@ -33,5 +33,5 @@ keywords:
- web-ui
- agent
license: Apache-2.0
version: 1.20.3
version: 1.22.1
date-released: '2026-03-02'
+1 -1
View File
@@ -78,6 +78,7 @@ You have 5 ways to install Portabase:
| **Valkey** | ✅ Stable | 7.2+ | No |
| **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes |
| **MSSQL Server** | ✅ Stable | 2017, 2019, 2022, Azure SQL | Yes |
| **Docker Volume** | ✅ Stable | Docker Engine 20.10+ | Yes |
See the [Database Servers documentation](https://portabase.io/docs/agent/db) for version-specific backup and restore details.
@@ -111,4 +112,3 @@ Distributed under the Apache License. See `LICENSE.txt` for more details.
[ShadcnUI-url]: https://ui.shadcn.com/
[Docker-url]: https://www.docker.com/
@@ -81,7 +81,7 @@ export default async function RoutePage(
</PageDescription>
)}
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
<AgentContentPage agent={agent} edgeKey={edgeKey} />
<AgentContentPage agent={agent} edgeKey={edgeKey} canDeleteDatabases={true} />
</PageContent>
</Page>
);
@@ -3,6 +3,7 @@ import { Page, PageContent, PageTitle } from "@/features/layout/components/page"
import { ButtonDeleteProject } from "@/features/projects/components/project-delete-button";
import { CardsWithPagination } from "@/components/common/cards-with-pagination";
import { ProjectDatabaseCard } from "@/features/projects/components/project-database-card";
import { ProjectDatabaseSelection } from "@/features/projects/components/project-database-selection";
import { notFound, redirect } from "next/navigation";
import { db } from "@/db";
import { eq } from "drizzle-orm";
@@ -87,20 +88,27 @@ export default async function RoutePage(
</div>
<PageContent className="flex flex-col w-full h-full">
{proj.databases.length > 0 ? (
<CardsWithPagination
data={[...proj.databases].sort(
(a, b) =>
new Date(b.createdAt).getTime() -
new Date(a.createdAt).getTime(),
)}
organizationSlug={organization.slug}
// @ts-ignore
cardItem={ProjectDatabaseCard}
cardsPerPage={20}
numberOfColumns={3}
pageSizeOptions={[10, 20, 50]}
extendedProps={proj}
/>
isMember ? (
<CardsWithPagination
data={[...proj.databases].sort(
(a, b) =>
new Date(b.createdAt).getTime() -
new Date(a.createdAt).getTime(),
)}
organizationSlug={organization.slug}
// @ts-ignore
cardItem={ProjectDatabaseCard}
cardsPerPage={20}
numberOfColumns={3}
pageSizeOptions={[10, 20, 50]}
extendedProps={proj}
/>
) : (
<ProjectDatabaseSelection
projectId={proj.id}
databases={proj.databases}
/>
)
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground py-20">
<p className="text-lg font-medium">No databases found</p>
@@ -94,7 +94,7 @@ export default async function RoutePage(
</PageDescription>
)}
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
<AgentContentPage agent={agent} edgeKey={edgeKey}/>
<AgentContentPage agent={agent} edgeKey={edgeKey} canDeleteDatabases={!!agent.organizationId}/>
</PageContent>
</Page>
);
@@ -47,7 +47,9 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
const organizationWithMembers = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.id, organization.id),
with: {
projects: true,
projects: {
where: eq(drizzleDb.schemas.project.isArchived, false),
},
members: {
with: {
user: true,
@@ -29,7 +29,7 @@ export default async function RoutePage(props: PageParams<{}>) {
if (!org) notFound();
const projects = await db.query.project.findMany({
where: eq(drizzleDb.schemas.project.organizationId, org.id),
where: and(eq(drizzleDb.schemas.project.organizationId, org.id), eq(drizzleDb.schemas.project.isArchived, false))
});
const projectIds = projects.map(project => project.id);
+6 -5
View File
@@ -4,7 +4,7 @@ import {Agent} from "@/db/schema/08_agent";
import {DatabaseWith} from "@/db/schema/07_database";
import * as drizzleDb from "@/db";
import {db, db as dbClient} from "@/db";
import {and, eq, inArray} from "drizzle-orm";
import {and, eq, inArray, desc, sql} from "drizzle-orm";
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
import {withUpdatedAt} from "@/db/utils";
import {Setting} from "@/db/schema/01_setting";
@@ -77,10 +77,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
})
.returning();
if (databaseCreated) {
await dbClient
.insert(drizzleDb.schemas.healthcheckLog)
.values({
@@ -123,7 +120,11 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
where: and(
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"])
)
),
orderBy: [
sql`case when ${drizzleDb.schemas.backup.status} = 'waiting' then 0 else 1 end`,
desc(drizzleDb.schemas.backup.createdAt)
]
})
const restoration = await dbClient.query.restoration.findFirst({
+55 -6
View File
@@ -1,11 +1,16 @@
import {NextResponse} from "next/server";
import fs from "fs";
import path from "path";
import {and, eq} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {env} from "@/env.mjs";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/tus/hooks"});
const FILE_PATH_RE = /^backups\/\d{4}-\d{2}-\d{2}\/[A-Za-z0-9._-]+$/;
export async function POST(request: Request) {
try {
const body = await request.json();
@@ -24,17 +29,61 @@ export async function POST(request: Request) {
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});
if (!filePath || !FILE_PATH_RE.test(filePath)) {
log.warn({filePath}, "Rejected invalid X-File-Path in TUS hook");
return NextResponse.json({error: "Invalid file path"}, {status: 400});
}
const generatedId = headers["X-Generated-Id"]?.[0];
const backupStorageId = headers["X-Backup-Storage-Id"]?.[0];
console.log(`Backup Storage ID : ${backupStorageId}`);
console.log(`generatedId : ${generatedId}`);
if (backupStorageId) {
if (!generatedId) {
log.warn("Missing X-Generated-Id in TUS hook");
return NextResponse.json({error: "Forbidden"}, {status: 403});
}
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
});
if (!database) {
log.warn({generatedId}, "No database for X-Generated-Id in TUS hook");
return NextResponse.json({error: "Forbidden"}, {status: 403});
}
const storage = await db.query.backupStorage.findFirst({
where: and(
eq(drizzleDb.schemas.backupStorage.id, backupStorageId),
),
with: {backup: true},
});
if (!storage || storage.backup?.databaseId !== database.id) {
log.warn({backupStorageId, generatedId}, "No pending backup storage matches TUS hook");
return NextResponse.json({error: "Forbidden"}, {status: 403});
}
} else {
log.warn("TUS hook without X-Backup-Storage-Id (legacy agent)");
}
const uploadDir = path.join(env.PRIVATE_PATH!, "/uploads/");
const oldFilePath = path.join(uploadDir, "tmp", id);
const newFilePath = path.join(uploadDir, filePath);
const resolvedBase = path.resolve(uploadDir);
const newFilePath = path.resolve(resolvedBase, filePath);
const rel = path.relative(resolvedBase, newFilePath);
if (rel.startsWith("..") || path.isAbsolute(rel)) {
log.warn({filePath}, "Rejected path traversal in TUS hook");
return NextResponse.json({error: "Invalid file path"}, {status: 400});
}
fs.mkdirSync(path.dirname(newFilePath), {recursive: true});
@@ -74,7 +123,7 @@ export async function POST(request: Request) {
}
return NextResponse.json({});
} catch (error) {
log.error({error: error},"TUS Hook error");
log.error({error: error}, "TUS Hook error");
return NextResponse.json({error: "Internal server error"}, {status: 500});
}
}
}
+6 -1
View File
@@ -14,7 +14,12 @@ const queryClient = new QueryClient();
export const Providers = (props: ProviderProps) => {
return (
<Suspense fallback={null}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem
storageKey="portabase_theme"
>
<ThemeMetaUpdater />
<QueryClientProvider client={queryClient}>
<ErrorLayout>
+5 -5
View File
@@ -1,10 +1,10 @@
services:
app:
# build:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: prod
image: portabase/portabase:1
build:
context: .
dockerfile: docker/dockerfile/Dockerfile
target: prod
# image: portabase/portabase:1
ports:
- "8887:80"
environment:
+46
View File
@@ -99,12 +99,58 @@ services:
# clientEmail: "fake@test.iam.gserviceaccount.com",
# privateKey: "unused-when-emulated",
rustfs:
image: rustfs/rustfs:latest
container_name: rustfs
environment:
- RUSTFS_ROOT_USER=admin
- RUSTFS_ROOT_PASSWORD=root-password
- RUSTFS_ACCESS_KEY=access-key
- RUSTFS_SECRET_KEY=secret-key
- RUSTFS_VOLUMES=/data
- RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_CONSOLE_ADDRESS=:9001
volumes:
- rustfs-data:/data
healthcheck:
test: [ "CMD", "curl", "-sf", "http://localhost:9000/health" ]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
ports:
- "9001:9001"
- "9000:9000"
extra_hosts:
- "localhost:host-gateway"
networks:
- portabase
rustfs-init:
image: rustfs/rc:latest
container_name: rustfs-init
depends_on:
rustfs:
condition: service_healthy
entrypoint: >
/bin/sh -c "
until (/usr/bin/rc alias set rustfs http://rustfs:9000 access-key secret-key) do echo '...waiting...' && sleep 1; done;
/usr/bin/rc mb rustfs/portabase;
echo 'RustFS initialization complete';
tail -f /dev/null
"
networks:
- portabase
volumes:
postgres-data:
azurite-data:
gcs-data:
gcs-storage:
rustfs-data:
networks:
portabase:
+26 -21
View File
@@ -1,11 +1,33 @@
FROM --platform=$BUILDPLATFORM node:22-bullseye AS base
FROM --platform=$BUILDPLATFORM node:22-slim AS runtime-base
RUN apt-get update && apt-get install -y \
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
bash \
tzdata \
libc6 \
nginx \
gnupg \
&& 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 bookworm-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list
RUN apt-get update && apt-get install -y --no-install-recommends \
postgresql-18 \
postgresql-client-18 \
postgresql-contrib-18 \
&& rm -rf /var/lib/apt/lists/*
ENV PATH="/usr/lib/postgresql/18/bin:${PATH}"
FROM runtime-base AS build-base
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
g++ \
make \
@@ -13,25 +35,9 @@ RUN apt-get update && apt-get install -y \
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
@@ -51,7 +57,7 @@ FROM scratch AS tusd-dist
COPY --from=tusd-base /tusddist/tusd /tusd
FROM base AS build-env
FROM build-base AS build-env
RUN corepack enable && corepack prepare pnpm@latest --activate
@@ -83,7 +89,7 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm run build
FROM base AS prod
FROM runtime-base AS prod
WORKDIR /app
ENV NODE_ENV=production
@@ -105,7 +111,6 @@ COPY --from=builder /app/drizzle.config.ts ./
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
COPY --from=tusd-dist /tusd /usr/local/bin/tusd
RUN chmod +x /usr/local/bin/tusd
+5
View File
@@ -30,6 +30,11 @@ http {
proxy_set_header Connection "upgrade";
}
location = /api/tus/hooks {
deny all;
return 404;
}
location / {
proxy_pass http://127.0.0.1:3000;
+3
View File
@@ -0,0 +1,3 @@
{
"ignoreDependencies": ["pino-pretty"]
}
+5 -38
View File
@@ -1,6 +1,6 @@
{
"name": "portabase",
"version": "1.20.3",
"version": "1.22.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
@@ -27,52 +27,38 @@
"@onboardjs/react": "1.0.0-rc.5",
"@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.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.11",
"@tanstack/react-query": "^5.100.14",
"@tanstack/react-table": "^8.21.3",
"@types/nodemailer": "^6.4.23",
"@types/ws": "^8.18.1",
"@zenstackhq/runtime": "2.14.2",
"argon2": "^0.43.1",
"bcrypt": "^6.0.0",
"better-auth": "1.6.11",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.3.0",
"dockerode": "^4.0.12",
"dotenv": "^16.6.1",
"drizzle-orm": "0.45.2",
"drizzle-zod": "0.8.3",
"embla-carousel-react": "^8.6.0",
"googleapis": "^170.1.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.553.0",
@@ -82,33 +68,22 @@
"next-safe-action": "^7.10.8",
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
"node-forge": "^1.4.0",
"nodemailer": "8.0.5",
"npm-check-updates": "^18.3.1",
"pg": "^8.21.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"prettier": "^3.8.3",
"react": "^19.2.6",
"react-day-picker": "9.7.0",
"react-dom": "^19.2.6",
"react-dropzone": "^14.4.1",
"react-email": "^4.3.2",
"react-hook-form": "^7.76.1",
"react-qr-code": "^2.0.21",
"react-resizable-panels": "^3.0.6",
"react-twc": "^1.5.1",
"react-use-measure": "^2.1.7",
"recharts": "^2.15.4",
"server-only": "^0.0.1",
"sharp": "^0.34.5",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"swiper": "^12.1.4",
"tailwind-merge": "^3.6.0",
"uuid": "^11.1.1",
"vaul": "^1.1.2",
"ws": "^8.21.0",
"zod": "4.3.6"
},
"devDependencies": {
@@ -119,29 +94,21 @@
"@release-it/conventional-changelog": "^10.0.6",
"@tailwindcss/postcss": "^4.3.0",
"@types/canvas-confetti": "^1.9.0",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.19.19",
"@types/node-forge": "^1.3.14",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@zenstackhq/openapi": "^2.22.1",
"@zenstackhq/tanstack-query": "^2.22.2",
"baseline-browser-mapping": "^2.10.32",
"drizzle-kit": "^0.31.10",
"esbuild": "^0.27.7",
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.6",
"eslint-plugin-tailwindcss": "^3.18.3",
"framer-motion": "^12.40.0",
"node-pty": "^1.1.0",
"pino-pretty": "^13.1.3",
"postcss": "8.5.10",
"postcss-load-config": "^6.0.1",
"release-it": "^19.2.4",
"tailwindcss": "^4.3.0",
"tsx": "^4.22.3",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"zenstack": "2.14.2"
"typescript": "^5.9.3"
},
"packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26"
}
+61 -2520
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+37 -8
View File
@@ -1,6 +1,4 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import {useState} from "react";
import {Card} from "@/components/ui/card";
@@ -8,28 +6,54 @@ import {ConnectionIndicator} from "@/components/common/connection-indicator";
import {formatDateLastContact} from "@/utils/date-formatting";
import {Database} from "@/db/schema/07_database";
import {ChevronRight, Activity, Fingerprint, Copy, Check} from "lucide-react";
import {cn} from "@/lib/utils";
export type DatabaseCardProps = {
data: Database;
withDetails?: boolean;
selectable?: boolean;
selected?: boolean;
onToggleSelect?: (databaseId: string) => void;
deleteButton?: React.ReactNode;
};
export const DatabaseCard = (props: DatabaseCardProps) => {
const {data: database, withDetails = true} = props;
const {data: database, withDetails = true, selectable = false, selected = false, onToggleSelect, deleteButton} = props;
const [isCopied, setIsCopied] = useState(false);
const handleCopy = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
navigator.clipboard.writeText(database.agentDatabaseId);
navigator.clipboard.writeText(database.agentDatabaseId ?? "");
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
};
return (
<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">
<div className="flex items-start justify-between mb-2">
className={cn(
"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",
selected && "ring-1 ring-primary border-primary",
)}>
{selectable && (
<button
type="button"
aria-pressed={selected}
aria-label={selected ? "Deselect database" : "Select database"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleSelect?.(database.id);
}}
className={cn(
"absolute top-3 left-3 z-20 w-5 h-5 rounded-md border-2 flex items-center justify-center transition-colors",
selected ? "bg-primary border-primary text-primary-foreground" : "border-muted-foreground/50 bg-background/70",
)}
>
{selected && <Check className="w-3 h-3" />}
</button>
)}
<div className={cn("flex items-start justify-between mb-2", selectable && "pl-7")}>
<div
className="relative w-12 h-12 p-2 bg-background rounded-xl border border-border/50 shadow-sm flex items-center justify-center group-hover:border-primary/30 transition-all duration-300 group-hover:scale-105">
<Image
@@ -82,7 +106,12 @@ export const DatabaseCard = (props: DatabaseCardProps) => {
</div>
</div>
{withDetails && (
{deleteButton ? (
<div className="mt-4 flex items-center justify-end pt-3 border-t border-border/50"
onClick={(e) => e.preventDefault()}>
{deleteButton}
</div>
) : withDetails ? (
<div className="mt-4 flex items-center justify-between pt-3 border-t border-border/50">
<span
className="text-[10px] font-black uppercase tracking-widest text-primary opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-x-[-10px] group-hover:translate-x-0">Open</span>
@@ -93,7 +122,7 @@ export const DatabaseCard = (props: DatabaseCardProps) => {
<ChevronRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform"/>
</div>
</div>
)}
) : null}
</Card>
);
};
@@ -0,0 +1 @@
ALTER TYPE "public"."dbms_status" ADD VALUE 'docker-volume';
@@ -0,0 +1,2 @@
ALTER TABLE "databases" ALTER COLUMN "agent_database_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "databases" ALTER COLUMN "agent_id" DROP NOT NULL;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -484,6 +484,20 @@
"when": 1782575192480,
"tag": "0068_busy_zeigeist",
"breakpoints": true
},
{
"idx": 69,
"version": "7",
"when": 1782937885439,
"tag": "0069_worried_alex_wilder",
"breakpoints": true
},
{
"idx": 70,
"version": "7",
"when": 1783264084846,
"tag": "0070_illegal_joseph",
"breakpoints": true
}
]
}
+1 -2
View File
@@ -13,7 +13,7 @@ import {JobLog, jobLog} from "@/db/schema/17_job-log";
export const database = pgTable("databases", {
id: uuid("id").primaryKey().defaultRandom(),
agentDatabaseId: uuid("agent_database_id").notNull().defaultRandom(),
agentDatabaseId: uuid("agent_database_id").defaultRandom(),
name: text("name").notNull(),
dbms: dbmsEnum("dbms").notNull(),
description: text("description"),
@@ -22,7 +22,6 @@ export const database = pgTable("databases", {
backupToRestore: text("backup_to_restore"),
healthErrorCount: integer("health_error_count"),
agentId: uuid("agent_id")
.notNull()
.references(() => agent.id, {onDelete: "cascade"}),
lastContact: timestamp("last_contact"),
projectId: uuid("project_id")
+1 -1
View File
@@ -2,7 +2,7 @@ import {pgEnum} from "drizzle-orm/pg-core";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "postgresql-cluster", "mysql", "mariadb", "mongodb", "sqlite", "redis", "valkey", "firebird", "mssql"]);
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "postgresql-cluster", "mysql", "mariadb", "mongodb", "sqlite", "redis", "valkey", "firebird", "mssql", "docker-volume"]);
export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
+7 -4
View File
@@ -16,10 +16,13 @@ export async function getOrganizationAvailableDatabases(
projectId?: string,
) {
const availableDatabases = (await db.query.database.findMany({
where: (db, { eq, or, isNull }) =>
projectId
? or(isNull(db.projectId), eq(db.projectId, projectId))
: isNull(db.projectId),
where: (db, { eq, or, and, isNull }) =>
and(
projectId
? or(isNull(db.projectId), eq(db.projectId, projectId))
: isNull(db.projectId),
isNull(db.deletedAt),
),
with: {
agent: {
with: {
+5 -2
View File
@@ -2,7 +2,7 @@
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { and, eq, gte, isNotNull, lt } from "drizzle-orm";
import { and, eq, gte, isNotNull, isNull, lt } from "drizzle-orm";
import { dispatchNotification } from "@/features/notifications/utils/notifications.dispatch";
import { EventPayload } from "@/features/notifications/types";
import { logger } from "@/lib/logger";
@@ -113,7 +113,10 @@ export async function checkAgentsHealthError() {
export async function checkDatabasesHealthError() {
const databases = await db.query.database.findMany({
where: isNotNull(drizzleDb.schemas.database.lastContact),
where: and(
isNotNull(drizzleDb.schemas.database.lastContact),
isNull(drizzleDb.schemas.database.deletedAt),
),
with: {
agent: true,
alertPolicies: true,
+7 -3
View File
@@ -2,7 +2,7 @@
import { getOrganization } from "@/lib/auth/auth";
import { db } from "@/db";
import { and, eq } from "drizzle-orm";
import { and, eq, isNull } from "drizzle-orm";
import * as drizzleDb from "@/db";
import { project } from "@/db/schema/06_project";
@@ -10,7 +10,9 @@ export async function getOrganizationProject(organizationId: string) {
return db.query.project.findFirst({
where: eq(project.organizationId, organizationId),
with: {
databases: true,
databases: {
where: isNull(drizzleDb.schemas.database.deletedAt),
},
},
});
}
@@ -39,7 +41,9 @@ export const getOrganizationProjectDatabases = async ({
eq(drizzleDb.schemas.project.id, projectId),
),
with: {
databases: true,
databases: {
where: isNull(drizzleDb.schemas.database.deletedAt),
},
},
});
+2
View File
@@ -64,6 +64,7 @@ export const env = createEnv({
process.env.NODE_ENV === "production" ? "0 * * * *" : "* * * * *",
),
STALE_BACKUP_THRESHOLD_HOURS: z.coerce.number().default(6),
AUTH_OIDC_ID: z.string().optional().default("oidc"),
AUTH_OIDC_TITLE: z.string().optional(),
@@ -147,6 +148,7 @@ export const env = createEnv({
RETENTION_CRON: process.env.RETENTION_CRON,
CLEANING_HEALTHCHECK_LOGS_CRON: process.env.CLEANING_HEALTHCHECK_LOGS_CRON,
STALE_BACKUP_THRESHOLD_HOURS: process.env.STALE_BACKUP_THRESHOLD_HOURS,
AUTH_OIDC_ID: process.env.AUTH_OIDC_ID,
AUTH_OIDC_TITLE: process.env.AUTH_OIDC_TITLE,
@@ -3,7 +3,7 @@ import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import {db} from "@/db";
import {and, eq, inArray} from "drizzle-orm";
import {and, eq, inArray, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {AgentWith} from "@/db/schema/08_agent";
import {withUpdatedAt} from "@/db/utils";
@@ -25,7 +25,9 @@ export const updateAgentOrganizationsAction = userAction
where: eq(drizzleDb.schemas.agent.id, agentId),
with: {
organizations: true,
databases: true
databases: {
where: isNull(drizzleDb.schemas.database.deletedAt),
},
}
}) as AgentWith;
@@ -70,7 +72,7 @@ export const updateAgentOrganizationsAction = userAction
if (projectIds.length > 0) {
const databases = await db.query.database.findMany({
where: (db, { inArray }) => inArray(db.projectId, projectIds),
where: (db, { inArray, and, isNull }) => and(inArray(db.projectId, projectIds), isNull(db.deletedAt)),
columns: { id: true }
});
+4 -2
View File
@@ -2,7 +2,7 @@
import {action, ActionError, userAction} from "@/lib/safe-actions/actions";
import {AgentSchema} from "@/features/agents/schemas/agents.schema";
import {z} from "zod";
import {eq, and, ne, count, desc} from "drizzle-orm";
import {eq, and, ne, count, desc, isNull} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {slugify} from "@/utils/slugify";
@@ -87,7 +87,9 @@ export const getAgentAction = userAction.schema(z.string()).action(async ({parse
const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, parsedInput),
with: {
databases: true
databases: {
where: isNull(drizzleDb.schemas.database.deletedAt),
},
}
});
@@ -23,9 +23,10 @@ import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
type AgentContentPageProps = {
edgeKey: string;
agent: AgentWithDatabases
canDeleteDatabases?: boolean
}
export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPageProps) => {
export const AgentContentPage = ({edgeKey, agent: initialAgent, canDeleteDatabases = false}: AgentContentPageProps) => {
const {data} = useQuery({
queryKey: ["agent-data", initialAgent.id],
@@ -121,6 +122,7 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)}
cardItem={AgentDatabaseCard}
canDeleteDatabases={canDeleteDatabases}
/>
</div>
)}
@@ -1,14 +1,29 @@
"use client";
import {Database} from "@/db/schema/07_database";
import {DatabaseCard} from "@/components/common/database-card";
import { Database } from "@/db/schema/07_database";
import { DatabaseCard } from "@/components/common/database-card";
import { DatabaseDeleteButton } from "@/features/agents/components/database-delete-button";
export type agentDatabaseCardProps = {
export type AgentDatabaseCardProps = {
data: Database;
canDeleteDatabases?: boolean;
};
export const AgentDatabaseCard = (props: agentDatabaseCardProps) => {
const {data: database} = props;
export const AgentDatabaseCard = (props: AgentDatabaseCardProps) => {
const { data: database, canDeleteDatabases = false } = props;
return <DatabaseCard withDetails={false} data={database}/>;
};
return (
<DatabaseCard
withDetails={false}
data={database}
deleteButton={
canDeleteDatabases && database.agentId ? (
<DatabaseDeleteButton
databaseId={database.id}
agentId={database.agentId}
/>
) : undefined
}
/>
);
};
@@ -0,0 +1,61 @@
"use client";
import {Trash2} from "lucide-react";
import {ButtonWithConfirm} from "@/components/common/button-with-confirm";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {toast} from "sonner";
import {deleteDatabaseAction} from "@/features/database/actions/database-delete.action";
export type DatabaseDeleteButtonProps = {
databaseId: string;
agentId: string;
};
export const DatabaseDeleteButton = (props: DatabaseDeleteButtonProps) => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: () => deleteDatabaseAction({databaseId: props.databaseId}),
onSuccess: async (result: any) => {
if (result.data?.success) {
toast.success(result.data.actionSuccess.message);
await queryClient.invalidateQueries({queryKey: ["agent-data", props.agentId]});
} else {
toast.error(result.data?.actionError?.message || "Unknown error occurred.");
}
},
onError: () => {
toast.error("Failed to delete database.");
},
});
return (
<ButtonWithConfirm
title="Delete database"
description="Are you sure you want to delete this database? It will be unlinked from this agent."
button={{
main: {
text: "",
variant: "outline",
size: "sm",
icon: <Trash2 color="red"/>,
},
confirm: {
className: "w-full",
text: "Delete",
icon: <Trash2/>,
variant: "destructive",
onClick: () => {
mutation.mutate();
},
},
cancel: {
className: "w-full",
text: "Cancel",
variant: "outline",
},
}}
isPending={mutation.isPending}
/>
);
};
@@ -105,7 +105,7 @@ export const StorageS3Form = ({ form }: StorageS3FormProps) => {
<FormItem>
<FormLabel>Use SSL</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
<Switch checked={field.value ?? true} onCheckedChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
@@ -2,7 +2,7 @@
import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import {and, eq, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {BackupWith, RestorationWith} from "@/db/schema/07_database";
import {getOrganizationChannels} from "@/db/services/notification-channel";
@@ -19,7 +19,10 @@ export const getDatabaseDataAction = userAction
const {databaseId} = parsedInput;
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, databaseId),
where: and(
eq(drizzleDb.schemas.database.id, databaseId),
isNull(drizzleDb.schemas.database.deletedAt),
),
with: {
project: true,
retentionPolicy: true,
@@ -0,0 +1,58 @@
"use server";
import {z} from "zod";
import {and, inArray, isNull} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {ServerActionResult} from "@/types/action-type";
import {userAction} from "@/lib/safe-actions/actions";
import {assertDatabasesInOrgProject} from "@/features/database/actions/bulk-restore.action";
const bulkSchema = z.object({
projectId: z.uuid(),
databaseIds: z.array(z.uuid()).min(1),
});
export const bulkBackupAction = userAction
.schema(bulkSchema)
.action(async ({parsedInput}): Promise<ServerActionResult<{queued: number; skipped: {databaseId: string; reason: string}[]}>> => {
try {
const {projectId, databaseIds} = parsedInput;
await assertDatabasesInOrgProject(projectId, databaseIds);
const activeBackups = await db.query.backup.findMany({
where: and(
inArray(drizzleDb.schemas.backup.databaseId, databaseIds),
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"]),
isNull(drizzleDb.schemas.backup.deletedAt),
),
columns: {databaseId: true},
});
const busy = new Set(activeBackups.map((b) => b.databaseId));
const toQueue = databaseIds.filter((id) => !busy.has(id));
const skipped = [...busy].map((databaseId) => ({databaseId, reason: "backup already in progress"}));
if (toQueue.length > 0) {
await db
.insert(drizzleDb.schemas.backup)
.values(toQueue.map((databaseId) => ({databaseId, status: "waiting" as const})));
}
return {
success: true,
value: {queued: toQueue.length, skipped},
actionSuccess: {
message: `Queued ${toQueue.length} backup(s)${skipped.length ? `, ${skipped.length} skipped` : ""}.`,
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to queue backups.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -0,0 +1,167 @@
"use server";
import {z} from "zod";
import {and, desc, eq, inArray, isNull} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {ServerActionResult} from "@/types/action-type";
import {userAction, ActionError} from "@/lib/safe-actions/actions";
import {getOrganization} from "@/lib/auth/auth";
export async function assertDatabasesInOrgProject(projectId: string, databaseIds: string[]) {
const organization = await getOrganization({});
if (!organization) throw new ActionError("No active organization.");
const project = await db.query.project.findFirst({
where: and(
eq(drizzleDb.schemas.project.id, projectId),
eq(drizzleDb.schemas.project.organizationId, organization.id),
),
with: {databases: {where: isNull(drizzleDb.schemas.database.deletedAt)}},
});
if (!project) throw new ActionError("Project not found.");
const allowed = new Set(project.databases.map((d) => d.id));
for (const id of databaseIds) {
if (!allowed.has(id)) throw new ActionError("Database not in project.");
}
}
export type RestorePreviewRow = {
databaseId: string;
name: string;
backupId?: string;
backupStorageId?: string;
backupDate?: string;
restorable: boolean;
reason?: string;
};
async function resolveLatestRestorable(projectId: string, databaseIds: string[]): Promise<RestorePreviewRow[]> {
const project = await db.query.project.findFirst({
where: eq(drizzleDb.schemas.project.id, projectId),
with: {databases: {where: isNull(drizzleDb.schemas.database.deletedAt)}},
});
const nameById = new Map((project?.databases ?? []).map((d) => [d.id, d.name] as const));
const rows: RestorePreviewRow[] = [];
for (const databaseId of databaseIds) {
const activeRestore = await db.query.restoration.findFirst({
where: and(
eq(drizzleDb.schemas.restoration.databaseId, databaseId),
inArray(drizzleDb.schemas.restoration.status, ["waiting", "ongoing"]),
),
});
if (activeRestore) {
rows.push({
databaseId,
name: nameById.get(databaseId) ?? databaseId,
restorable: false,
reason: "restore already in progress",
});
continue;
}
const backups = await db.query.backup.findMany({
where: and(
eq(drizzleDb.schemas.backup.databaseId, databaseId),
isNull(drizzleDb.schemas.backup.deletedAt),
),
orderBy: desc(drizzleDb.schemas.backup.createdAt),
});
let picked: RestorePreviewRow | undefined;
for (const backup of backups) {
const storage = await db.query.backupStorage.findFirst({
where: and(
eq(drizzleDb.schemas.backupStorage.backupId, backup.id),
eq(drizzleDb.schemas.backupStorage.status, "success"),
isNull(drizzleDb.schemas.backupStorage.deletedAt),
),
orderBy: desc(drizzleDb.schemas.backupStorage.createdAt),
});
if (storage) {
picked = {
databaseId,
name: nameById.get(databaseId) ?? databaseId,
backupId: backup.id,
backupStorageId: storage.id,
backupDate: backup.createdAt?.toISOString(),
restorable: true,
};
break;
}
}
rows.push(picked ?? {databaseId, name: nameById.get(databaseId) ?? databaseId, restorable: false, reason: "no successful backup"});
}
return rows;
}
const bulkSchema = z.object({
projectId: z.uuid(),
databaseIds: z.array(z.uuid()).min(1),
});
export const bulkRestorePreviewAction = userAction
.schema(bulkSchema)
.action(async ({parsedInput}): Promise<ServerActionResult<RestorePreviewRow[]>> => {
try {
const {projectId, databaseIds} = parsedInput;
await assertDatabasesInOrgProject(projectId, databaseIds);
const rows = await resolveLatestRestorable(projectId, databaseIds);
return {success: true, value: rows};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to build restore preview.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
export const bulkRestoreLatestAction = userAction
.schema(bulkSchema)
.action(async ({parsedInput}): Promise<ServerActionResult<{queued: number; skipped: {databaseId: string; reason: string}[]}>> => {
try {
const {projectId, databaseIds} = parsedInput;
await assertDatabasesInOrgProject(projectId, databaseIds);
const rows = await resolveLatestRestorable(projectId, databaseIds);
const restorable = rows.filter((r) => r.restorable);
const skipped = rows
.filter((r) => !r.restorable)
.map((r) => ({databaseId: r.databaseId, reason: r.reason ?? "no successful backup"}));
if (restorable.length > 0) {
await db.insert(drizzleDb.schemas.restoration).values(
restorable.map((r) => ({
databaseId: r.databaseId,
backupId: r.backupId!,
backupStorageId: r.backupStorageId!,
status: "waiting" as const,
})),
);
}
return {
success: true,
value: {queued: restorable.length, skipped},
actionSuccess: {
message: `Queued ${restorable.length} restore(s)${skipped.length ? `, ${skipped.length} skipped` : ""}.`,
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to queue restores.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -0,0 +1,174 @@
"use server";
import {z} from "zod";
import {eq} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {Database} from "@/db/schema/07_database";
import {ServerActionResult} from "@/types/action-type";
import {userAction} from "@/lib/safe-actions/actions";
import {zString} from "@/lib/zod";
import {withUpdatedAt} from "@/db/utils";
import {currentUser} from "@/lib/auth/current-user";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
type DeleteDatabaseInput = {
databaseId: string;
};
class DatabaseNotFoundError extends Error {
constructor(databaseId: string) {
super(`Database not found or update failed: ${databaseId}`);
this.name = "DatabaseNotFoundError";
}
}
class UnauthorizedError extends Error {
constructor(databaseId: string) {
super(`Not authorized to delete this database: ${databaseId}`);
this.name = "UnauthorizedError";
}
}
async function assertCanDeleteDatabase(databaseId: string): Promise<void> {
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, databaseId),
with: {
agent: true,
},
});
if (!database || !database.agent) {
throw new DatabaseNotFoundError(databaseId);
}
const user = await currentUser();
if (!user) {
throw new UnauthorizedError(databaseId);
}
const agent = database.agent;
const isAdmin = user.role === "superadmin" || user.role === "admin";
let authorized: boolean;
if (agent.organizationId === null) {
authorized = isAdmin;
} else {
const organization = await getOrganization({});
const activeMember = await getActiveMember();
const canManage = activeMember
? computeOrganizationPermissions(activeMember).canManageAgents
: false;
const hasAccess =
!!organization && agent.organizationId === organization.id;
authorized = canManage && hasAccess;
}
if (!authorized) {
throw new UnauthorizedError(databaseId);
}
}
export async function deleteDatabaseService(input: DeleteDatabaseInput): Promise<Database> {
const {databaseId} = input;
await assertCanDeleteDatabase(databaseId);
await db
.delete(drizzleDb.schemas.retentionPolicy)
.where(eq(drizzleDb.schemas.retentionPolicy.databaseId, databaseId))
.execute();
await db
.delete(drizzleDb.schemas.alertPolicy)
.where(eq(drizzleDb.schemas.alertPolicy.databaseId, databaseId))
.execute();
await db
.delete(drizzleDb.schemas.storagePolicy)
.where(eq(drizzleDb.schemas.storagePolicy.databaseId, databaseId))
.execute();
const [updatedDatabase] = await db
.update(drizzleDb.schemas.database)
.set(
withUpdatedAt({
agentId: null,
agentDatabaseId: null,
projectId: null,
backupPolicy: null,
deletedAt: new Date(),
})
)
.where(eq(drizzleDb.schemas.database.id, databaseId))
.returning();
if (!updatedDatabase) {
throw new DatabaseNotFoundError(databaseId);
}
return updatedDatabase;
}
export const deleteDatabaseAction = userAction
.schema(
z.object({
databaseId: zString(),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Database>> => {
try {
const deletedDatabase = await deleteDatabaseService(parsedInput);
return {
success: true,
value: deletedDatabase,
actionSuccess: {
message: "Database has been successfully deleted.",
messageParams: {
databaseId: parsedInput.databaseId,
},
},
};
} catch (error) {
if (error instanceof UnauthorizedError) {
return {
success: false,
actionError: {
message: "Not authorized to delete this database.",
status: 403,
messageParams: {
databaseId: parsedInput.databaseId,
},
},
};
}
if (error instanceof DatabaseNotFoundError) {
return {
success: false,
actionError: {
message: "Database not found or update failed",
status: 404,
messageParams: {
databaseId: parsedInput.databaseId,
},
},
};
}
return {
success: false,
actionError: {
message: "Failed to delete database.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {
databaseId: parsedInput.databaseId,
},
},
};
}
});
@@ -5,7 +5,7 @@ import {db} from "@/db";
import * as drizzleDb from "@/db";
import {Backup} from "@/db/schema/07_database";
import {v4 as uuidv4} from "uuid";
import {eq} from "drizzle-orm";
import {and, eq, isNull} from "drizzle-orm";
import {z} from "zod";
import {storeBackupFiles} from "@/features/storages/utils/storages.helpers";
import {getFileExtension} from "@/utils/common";
@@ -19,7 +19,10 @@ export const uploadBackupAction = userAction
const databaseId = formData.get("databaseId") as string;
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, databaseId),
where: and(
eq(drizzleDb.schemas.database.id, databaseId),
isNull(drizzleDb.schemas.database.deletedAt),
),
with: {
project: true,
alertPolicies: true,
@@ -128,7 +128,9 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
channels={activeOrganizationStorageChannels}
organizationId={props.organizationId}
/>
<ImportModal database={database}/>
{database.dbms != "docker-volume" && (
<ImportModal database={database}/>
)}
<HealthModal database={database} healthLogs={data?.health ?? []}/>
</div>
@@ -4,7 +4,7 @@ import {ServerActionResult} from "@/types/action-type";
import {z} from "zod";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq, inArray} from "drizzle-orm";
import {and, eq, inArray, isNull} from "drizzle-orm";
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
import {v4 as uuidv4} from "uuid";
import {getTodayISODate} from "@/utils/date-formatting";
@@ -24,7 +24,10 @@ export const migrationAction = userAction.schema(
try {
const targetDatabase = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, targetDatabaseId),
where: and(
eq(drizzleDb.schemas.database.id, targetDatabaseId),
isNull(drizzleDb.schemas.database.deletedAt),
),
with: {
project: true,
retentionPolicy: true,
@@ -2,6 +2,7 @@ export const STEP_IDS = {
LOGIN: "login",
ACCOUNT_INFO: "account-info",
SECURITY: "security",
PREFERENCES: "preferences",
ORG_CREATE: "org-create",
//INVITE_MEMBERS: "invite-members",
NOTIFIER: "notifier",
@@ -19,6 +20,7 @@ export const STEP_ORDER: string[] = [
STEP_IDS.LOGIN,
STEP_IDS.ACCOUNT_INFO,
STEP_IDS.SECURITY,
STEP_IDS.PREFERENCES,
STEP_IDS.ORG_CREATE,
//STEP_IDS.INVITE_MEMBERS,
STEP_IDS.NOTIFIER,
@@ -61,9 +61,9 @@ export const OnboardingChecklist = () => {
<p
className={cn(
"text-sm pb-4",
"text-sm pb-4 ",
isCompleted && "text-zinc-400",
isCurrent && "text-white font-medium",
isCurrent && "text-primary font-medium",
!isCompleted && !isCurrent && "text-zinc-600",
)}
>
+2 -2
View File
@@ -84,8 +84,8 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
const org = await getUserOwnOrganization(user.id);
if (!org) {
meta.resumeStepId = "org-create";
return { stepId: "org-create", flowData: { meta } };
meta.resumeStepId = "preferences";
return { stepId: "preferences", flowData: { meta } };
}
const orgData = { id: org.id, name: org.name };
+10 -2
View File
@@ -2,6 +2,7 @@ import { OnboardingStep } from "@onboardjs/react";
import { StepLogin } from "@/features/onboarding/steps/step-login";
import { StepAccountInfo } from "@/features/onboarding/steps/step-account-info";
import { StepSecurity } from "@/features/onboarding/steps/step-security";
import { StepPreferences } from "@/features/onboarding/steps/step-preferences";
import { StepOrgCreate } from "@/features/onboarding/steps/step-org-create";
import { StepInviteMembers } from "@/features/onboarding/steps/step-invite-members";
import { StepNotifier } from "@/features/onboarding/steps/step-notifier";
@@ -20,19 +21,26 @@ export const onboardingSteps: OnboardingStep[] = [
component: StepLogin,
isSkippable: false,
nextStep: (ctx: any) =>
ctx.flowData?.meta?.hasExistingUsers ? "org-create" : "account-info",
ctx.flowData?.meta?.hasExistingUsers ? "preferences" : "account-info",
},
{
id: "account-info",
component: StepAccountInfo,
isSkippable: false,
nextStep: (ctx: any) =>
ctx.flowData?.meta?.passkeyEnabled ? "org-create" : "security",
ctx.flowData?.meta?.passkeyEnabled ? "preferences" : "security",
},
{
id: "security",
component: StepSecurity,
isSkippable: true,
skipToStep: "preferences",
nextStep: "preferences",
},
{
id: "preferences",
component: StepPreferences,
isSkippable: true,
skipToStep: "org-create",
nextStep: "org-create",
},
@@ -70,7 +70,16 @@ export const StepAgentCreate = () => {
</Button>
</div>
<Button type="button" onClick={() => next()}>
<Button
type="button"
onClick={() => next()}
disabled={!!name.trim() || createAgent.isPending}
title={
name.trim()
? "Add or clear the agent name before continuing"
: undefined
}
>
Continue
</Button>
</div>
@@ -35,6 +35,8 @@ export const StepDefaults = () => {
const [storageId, setStorageId] = useState<string | undefined>(
existingDefaults.storageId || undefined,
);
const [notifierOpen, setNotifierOpen] = useState(false);
const [storageOpen, setStorageOpen] = useState(false);
const selectNotifier = async (value: string) => {
const prev = notifierId;
@@ -56,6 +58,26 @@ export const StepDefaults = () => {
}
};
const clearNotifier = async () => {
const prev = notifierId;
setNotifierId(undefined);
try {
await updateNotificationSettingsAction({
name: "system",
data: { notificationChannelId: null },
});
await updateContext({
flowData: {
...state?.context.flowData,
defaults: { notifierId: undefined, storageId },
},
});
} catch {
setNotifierId(prev);
toast.error("Failed to clear default notifier");
}
};
const selectStorage = async (value: string) => {
const prev = storageId;
setStorageId(value);
@@ -76,6 +98,26 @@ export const StepDefaults = () => {
}
};
const clearStorage = async () => {
const prev = storageId;
setStorageId(undefined);
try {
await updateStorageSettingsAction({
name: "system",
data: { storageChannelId: null, encryption: false },
});
await updateContext({
flowData: {
...state?.context.flowData,
defaults: { notifierId, storageId: undefined },
},
});
} catch {
setStorageId(prev);
toast.error("Failed to clear default storage");
}
};
const onContinue = async () => {
await updateContext({
flowData: {
@@ -102,8 +144,10 @@ export const StepDefaults = () => {
<div className="flex flex-col gap-2">
<Label>Default notifier</Label>
<Select
value={selectedNotifier ? notifierId : undefined}
value={selectedNotifier ? notifierId : ""}
onValueChange={selectNotifier}
open={notifierOpen}
onOpenChange={setNotifierOpen}
disabled={notifiers.length === 0}
>
<SelectTrigger className="w-full">
@@ -128,7 +172,17 @@ export const StepDefaults = () => {
</SelectTrigger>
<SelectContent>
{notifiers.map((n) => (
<SelectItem key={n.id} value={n.id}>
<SelectItem
key={n.id}
value={n.id}
onPointerUp={(e) => {
if (n.id === notifierId) {
e.preventDefault();
clearNotifier();
setNotifierOpen(false);
}
}}
>
<div className="flex items-center gap-2 w-full min-w-0">
<div className="text-muted-foreground scale-90 shrink-0">
{getChannelIcon(n.provider)}
@@ -149,8 +203,10 @@ export const StepDefaults = () => {
<div className="flex flex-col gap-2">
<Label>Default storage</Label>
<Select
value={selectedStorage ? storageId : undefined}
value={selectedStorage ? storageId : ""}
onValueChange={selectStorage}
open={storageOpen}
onOpenChange={setStorageOpen}
disabled={storages.length === 0}
>
<SelectTrigger className="w-full">
@@ -175,7 +231,17 @@ export const StepDefaults = () => {
</SelectTrigger>
<SelectContent>
{storages.map((s) => (
<SelectItem key={s.id} value={s.id}>
<SelectItem
key={s.id}
value={s.id}
onPointerUp={(e) => {
if (s.id === storageId) {
e.preventDefault();
clearStorage();
setStorageOpen(false);
}
}}
>
<div className="flex items-center gap-2 w-full min-w-0">
<div className="text-muted-foreground scale-90 shrink-0">
{getChannelIcon(s.provider)}
@@ -0,0 +1,56 @@
"use client";
import { useOnboarding } from "@onboardjs/react";
import { useTheme } from "next-themes";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth/auth-client";
import { ThemeKey, ThemeSelector } from "@/components/common/theme-selector";
export const StepPreferences = () => {
const { next, updateContext, state } = useOnboarding();
const { theme: currentTheme, setTheme } = useTheme();
const preferences = state?.context.flowData.preferences ?? {
theme: (currentTheme ?? "system") as ThemeKey,
};
const selectTheme = async (theme: ThemeKey) => {
setTheme(theme);
try {
await authClient.updateUser({ theme });
await updateContext({
flowData: {
...state?.context.flowData,
preferences: { ...preferences, theme },
},
});
} catch {
toast.error("Failed to save theme preference");
setTheme(preferences.theme as ThemeKey);
}
};
const onContinue = async () => {
await next();
};
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-semibold">Your preferences</h1>
<p className="text-sm text-muted-foreground mt-1">
Optional personalise your workspace.
</p>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Theme</p>
<ThemeSelector value={preferences.theme} onSelect={selectTheme} />
</div>
<Button type="button" onClick={onContinue}>
Continue
</Button>
</div>
);
};
@@ -5,7 +5,7 @@ import {ProjectSchema} from "@/features/projects/schemas/projects.schema";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import {db} from "@/db";
import {and, eq, inArray} from "drizzle-orm";
import {and, eq, inArray, isNull} from "drizzle-orm";
import {Project} from "@/db/schema/06_project";
import * as drizzleDb from "@/db";
import {Database} from "@/db/schema/07_database";
@@ -88,7 +88,9 @@ export const updateProjectAction = userAction
const existing = await db.query.project.findFirst({
where: eq(drizzleDb.schemas.project.id, parsedInput.projectId),
with: {
databases: true,
databases: {
where: isNull(drizzleDb.schemas.database.deletedAt),
},
},
});
@@ -0,0 +1,37 @@
"use client";
import {DatabaseZap, RotateCcw, X, Loader2} from "lucide-react";
import {Button} from "@/components/ui/button";
export type BulkActionBarProps = {
count: number;
isPending: boolean;
onBackup: () => void;
onRestore: () => void;
onClear: () => void;
};
export const BulkActionBar = ({count, isPending, onBackup, onRestore, onClear}: BulkActionBarProps) => {
if (count === 0) return null;
return (
<div
className="fixed bottom-4 sm:bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-2 sm:gap-3 rounded-xl border border-primary/50 bg-card shadow-lg px-3 sm:px-4 py-2 sm:py-3 max-w-[calc(100vw-1.5rem)]">
<span className="flex-shrink-0 rounded-full bg-primary text-primary-foreground text-xs font-bold px-2.5 sm:px-3 py-1">
{count}
<span className="hidden sm:inline"> selected</span>
</span>
<Button size="sm" variant="outline" onClick={onBackup} disabled={isPending} title="Backup">
{isPending ? <Loader2 className="animate-spin"/> : <DatabaseZap/>}
<span className="hidden sm:inline">Backup</span>
</Button>
<Button size="sm" variant="outline" onClick={onRestore} disabled={isPending} title="Restore latest">
<RotateCcw/>
<span className="hidden sm:inline">Restore latest</span>
</Button>
<Button size="sm" variant="ghost" onClick={onClear} disabled={isPending} title="Clear">
<X/>
<span className="hidden sm:inline">Clear</span>
</Button>
</div>
);
};
@@ -0,0 +1,84 @@
"use client";
import {useState} from "react";
import {Loader2} from "lucide-react";
import {Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter} from "@/components/ui/dialog";
import {Button} from "@/components/ui/button";
import {Input} from "@/components/ui/input";
import type {RestorePreviewRow} from "@/features/database/actions/bulk-restore.action";
const CONFIRM_WORD = "restore";
export type BulkRestoreModalProps = {
open: boolean;
loading: boolean;
submitting: boolean;
rows: RestorePreviewRow[];
onConfirm: () => void;
onCancel: () => void;
};
export const BulkRestoreModal = (props: BulkRestoreModalProps) => {
const {open, loading, submitting, rows, onConfirm, onCancel} = props;
const [typed, setTyped] = useState("");
const restorableCount = rows.filter((r) => r.restorable).length;
const canConfirm = restorableCount > 0 && typed.trim().toLowerCase() === CONFIRM_WORD && !submitting;
return (
<Dialog open={open} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Restore {rows.length} database(s) to latest backup
</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
This overwrites current data and cannot be undone.
</p>
{loading ? (
<div className="flex items-center justify-center py-8"><Loader2 className="animate-spin" /></div>
) : (
<div className="max-h-64 overflow-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-muted-foreground border-b">
<th className="p-2">Database</th>
<th className="p-2">Will restore from</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.databaseId} className="border-b last:border-0">
<td className={`p-2 ${r.restorable ? "" : "opacity-50"}`}>{r.name}</td>
<td className="p-2">
{r.restorable
? <span className="text-green-600">{r.backupDate ? new Date(r.backupDate).toLocaleString() : "latest backup"}</span>
: <span className="text-amber-500">{r.reason ?? "no successful backup"} skipped</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{restorableCount > 0 && (
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Type &quot;{CONFIRM_WORD}&quot; to confirm</label>
<Input value={typed} onChange={(e) => setTyped(e.target.value)} placeholder={CONFIRM_WORD} />
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={submitting}>Cancel</Button>
<Button onClick={onConfirm} disabled={!canConfirm}>
{submitting && <Loader2 className="animate-spin" />}
{restorableCount > 0 ? `Restore ${restorableCount} database(s)` : "Nothing to restore"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,176 @@
"use client";
import {useMemo, useState} from "react";
import Link from "next/link";
import {useRouter} from "next/navigation";
import {useQueryClient} from "@tanstack/react-query";
import {toast} from "sonner";
import {Database} from "@/db/schema/07_database";
import {DatabaseCard} from "@/components/common/database-card";
import {Button} from "@/components/ui/button";
import {PaginationNavigation} from "@/components/common/pagination-navigation";
import {PaginationSize} from "@/components/common/pagination-size";
import {BulkActionBar} from "@/features/projects/components/bulk-action-bar";
import {BulkRestoreModal} from "@/features/projects/components/bulk-restore-modal";
import {bulkBackupAction} from "@/features/database/actions/bulk-backup.action";
import {
bulkRestorePreviewAction,
bulkRestoreLatestAction,
type RestorePreviewRow,
} from "@/features/database/actions/bulk-restore.action";
export type ProjectDatabaseSelectionProps = {
projectId: string;
databases: Database[];
};
export const ProjectDatabaseSelection = ({projectId, databases}: ProjectDatabaseSelectionProps) => {
const router = useRouter();
const queryClient = useQueryClient();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [pageSize, setPageSize] = useState(20);
const [currentPage, setCurrentPage] = useState(1);
const [pending, setPending] = useState(false);
const [restoreOpen, setRestoreOpen] = useState(false);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewRows, setPreviewRows] = useState<RestorePreviewRow[]>([]);
const sorted = useMemo(
() => [...databases].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
[databases],
);
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
const pageItems = sorted.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const selectedIds = Array.from(selected);
const toggle = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
const selectAll = () => setSelected(new Set(sorted.map((d) => d.id)));
const clear = () => setSelected(new Set());
const afterQueue = () => {
clear();
queryClient.invalidateQueries({queryKey: ["database-data"]});
router.refresh();
};
const handleBackup = async () => {
setPending(true);
const res = await bulkBackupAction({projectId, databaseIds: selectedIds});
setPending(false);
const data = res?.data;
if (data?.success) {
toast.success(data.actionSuccess?.message ?? "Backups queued.");
afterQueue();
} else {
toast.error(res?.serverError ?? data?.actionError?.message ?? "Failed to queue backups.");
}
};
const openRestore = async () => {
setRestoreOpen(true);
setPreviewLoading(true);
const res = await bulkRestorePreviewAction({projectId, databaseIds: selectedIds});
setPreviewLoading(false);
if (res?.data?.success && res.data.value) {
setPreviewRows(res.data.value);
} else {
toast.error(res?.serverError ?? "Failed to build preview.");
setRestoreOpen(false);
}
};
const confirmRestore = async () => {
setPending(true);
const res = await bulkRestoreLatestAction({projectId, databaseIds: selectedIds});
setPending(false);
setRestoreOpen(false);
const data = res?.data;
if (data?.success) {
toast.success(data.actionSuccess?.message ?? "Restores queued.");
afterQueue();
} else {
toast.error(res?.serverError ?? data?.actionError?.message ?? "Failed to queue restores.");
}
};
return (
<div className="flex flex-col h-full justify-between">
<div>
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-muted-foreground">{selected.size} selected</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={selectAll}>Select all</Button>
<Button variant="ghost" size="sm" onClick={clear} disabled={selected.size === 0}>Clear</Button>
</div>
</div>
<div className="grid h-max auto-rows-min gap-4 md:grid-cols-3">
{pageItems.map((database) => (
<Link
key={database.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"
href={`/dashboard/projects/${projectId}/database/${database.id}`}
>
<DatabaseCard
data={database}
selectable
selected={selected.has(database.id)}
onToggleSelect={toggle}
/>
</Link>
))}
</div>
</div>
<div className="flex items-center justify-end mt-4 gap-4">
<PaginationSize
pageSize={pageSize}
onPageSizeChange={(size) => {
setPageSize(size);
setCurrentPage(1);
}}
pageSizeOptions={[10, 20, 50]}
/>
<PaginationNavigation
className="justify-end"
totalPages={totalPages}
currentPage={currentPage}
goToPage={setCurrentPage}
goToPrevPage={() => setCurrentPage(Math.max(1, currentPage - 1))}
goToNextPage={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
maxVisiblePages={3}
/>
</div>
<BulkActionBar
count={selected.size}
isPending={pending}
onBackup={handleBackup}
onRestore={openRestore}
onClear={clear}
/>
<BulkRestoreModal
open={restoreOpen}
loading={previewLoading}
submitting={pending}
rows={previewRows}
onConfirm={confirmRestore}
onCancel={() => setRestoreOpen(false)}
/>
</div>
);
};
@@ -24,7 +24,7 @@ export const updateStorageSettingsAction = userAction
const [updatedSettings] = await db
.update(drizzleDb.schemas.setting)
.set(withUpdatedAt({
defaultStorageChannelId: data.storageChannelId,
defaultStorageChannelId: data.storageChannelId ?? null,
encryption: data.encryption,
}))
.where(eq(drizzleDb.schemas.setting.name, name))
@@ -1,7 +1,7 @@
import {z} from "zod";
export const DefaultStorageSchema = z.object({
storageChannelId: z.string(),
storageChannelId: z.string().optional().nullable(),
encryption: z.boolean(),
});
+5 -2
View File
@@ -1,14 +1,17 @@
import {ApiKeyContextUser} from "@/lib/api-v1/types";
import {getAccessibleDatabaseIds} from "@/lib/api-v1/services/databases";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import {and, eq, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
export async function resolveDatabaseAccess(id: string, user: ApiKeyContextUser) {
const accessibleIds = await getAccessibleDatabaseIds(user);
if (accessibleIds.includes(id)) return "ok";
const exists = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, id),
where: and(
eq(drizzleDb.schemas.database.id, id),
isNull(drizzleDb.schemas.database.deletedAt),
),
columns: { id: true },
});
return exists ? "forbidden" : "not_found";
+36 -1
View File
@@ -1,8 +1,10 @@
import {db} from "@/db";
import {and, eq, isNotNull, isNull} from "drizzle-orm";
import {and, eq, isNotNull, isNull, lt} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {withUpdatedAt} from "@/db/utils";
import {logger} from "@/lib/logger";
import {env} from "@/env.mjs";
import {sendNotificationsBackupRestore} from "@/features/notifications/utils/notifications.helpers";
const log = logger.child({module: "tasks/cleaning"});
@@ -23,6 +25,39 @@ export const backupCleanTask = async () => {
.where(eq(drizzleDb.schemas.backup.id, backup.id));
}
const staleCutoff = new Date(Date.now() - env.STALE_BACKUP_THRESHOLD_HOURS * 60 * 60 * 1000);
const staleOngoingBackups = await db.query.backup.findMany({
where: and(
isNull(drizzleDb.schemas.backup.deletedAt),
eq(drizzleDb.schemas.backup.status, "ongoing"),
lt(drizzleDb.schemas.backup.createdAt, staleCutoff)
)
});
log.debug(`Stale ongoing backups to fail: ${staleOngoingBackups.length}`);
for (const backup of staleOngoingBackups) {
await db.update(drizzleDb.schemas.backup).set(withUpdatedAt({
status: "failed",
}))
.where(eq(drizzleDb.schemas.backup.id, backup.id));
try {
const database = await db.query.database.findFirst({
where: and(
eq(drizzleDb.schemas.database.id, backup.databaseId),
isNull(drizzleDb.schemas.database.deletedAt),
),
with: {alertPolicies: true},
});
if (database) {
await sendNotificationsBackupRestore(database, "error_backup");
}
} catch (notifyError) {
log.error({name: "backupCleanTask", error: notifyError}, "Stale backup notification failed");
}
}
const failedBackups = await db.query.backup.findMany({
where: and(
isNull(drizzleDb.schemas.backup.deletedAt),
+1
View File
@@ -12,6 +12,7 @@ const log = logger.child({ module: "tasks/database" });
export const retentionCleanTask = async () => {
try {
const databases = await db.query.database.findMany({
where: isNull(drizzleDb.schemas.database.deletedAt),
with: {
retentionPolicy: true,
backups: {
+2 -2
View File
@@ -10,8 +10,8 @@ export function capitalizeFirstLetter(text: string): string {
}
export function isUUID(str: string) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
str,
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-7][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
str,
);
}