Compare commits

..
Author SHA1 Message Date
Charles GTE e91ef4c142 fix: docker-compose.prod.yml 2026-05-17 09:52:58 +02:00
Charles GTE 9b504ccd68 fix: proxy.ts 2026-05-17 09:46:03 +02:00
Charles GTE dd3f4411cf chore: add startupProbe and fix probe timing for cold-boot safety 2026-05-17 09:39:11 +02:00
Charles GTE 0ab94b6221 chore: add liveness and readiness probes to Helm deployment 2026-05-17 09:37:28 +02:00
Charles GTE 8477889cea chore: fix healthcheck start_period and clean up curl command 2026-05-17 09:36:49 +02:00
Charles GTE 2eb4a7a0fe chore: add healthcheck probe to prod docker-compose app service 2026-05-17 09:35:01 +02:00
Charles GTE 1f68adda90 feat: add GET /api/health liveness endpoint 2026-05-17 09:33:53 +02:00
Charles GTEandGitHub ffba2c56a3 fix: env
fix: env.mjs [skip-release]
2026-05-15 23:03:19 +02:00
github-actions[bot] 02be78a26e chore: release 1.14.1 2026-05-15 20:42:10 +00:00
Charles GTEandGitHub 711d4613ed fix: log noise #284
fix: logs noise
2026-05-15 22:41:21 +02:00
Charles GTE a5fe5225e1 fix: pin @better-auth/sso and @better-auth/passkey to 1.6.2 to fix build 2026-05-15 22:28:38 +02:00
Charles GTE b752d9437c chore: pnpm 2026-05-15 22:22:21 +02:00
Charles GTE 18df92e048 chore: update packages 2026-05-15 22:18:55 +02:00
Charles GTE 4e66b8ed04 fix: logs 2026-05-15 22:08:14 +02:00
Charles GTEandClaude Sonnet 4.6 4f14d16452 fix: replace console.log with Pino logger in server-side files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 22:05:13 +02:00
Charles GTE 592588ef76 docs: set LOG_LEVEL=info as dev default in .env.example 2026-05-15 22:01:19 +02:00
Charles GTE 3fec7c3392 docs: document LOG_LEVEL env var in .env.example 2026-05-15 21:59:00 +02:00
Charles GTE 2335caefd7 fix: downgrade cleaningJob logs to debug; fix catch log level 2026-05-15 21:57:56 +02:00
Charles GTE 194eedeaa8 fix: downgrade agent status ping log to debug 2026-05-15 21:57:21 +02:00
Charles GTE ecd09aedb9 fix: cache log level index at module init in loggingMiddleware 2026-05-15 21:56:37 +02:00
Charles GTE b5350b5fe3 fix: .gitignore 2026-05-15 21:55:19 +02:00
Charles GTE ceaafd5531 fix: suppress agent status ping logs below debug level 2026-05-15 21:55:01 +02:00
Charles GTE 654accad4a fix: use nullish coalescing for LOG_LEVEL fallback 2026-05-15 21:53:54 +02:00
Charles GTE 173c7894b4 fix: respect LOG_LEVEL env var in Pino logger 2026-05-15 21:52:51 +02:00
Charles GTE 1ab51d15f9 fix: added mailpit 2026-05-15 21:24:11 +02:00
Charles GTEandGitHub ddce51d2d3 fix: README.md [skip-release] (#280) 2026-05-12 20:03:49 +02:00
Charles GTEandGitHub fc9fbf4357 chore: update the README.md with mssql (#278) [skip-release] 2026-05-11 22:27:19 +02:00
github-actions[bot] 5e8cd7e0e6 chore: release 1.14.0 2026-05-11 20:09:21 +00:00
Charles GTEandGitHub 1eb5fbb983 feat: add support for mssql (#277)
* feat: add support for mssql

* fix: common.ts
2026-05-11 22:08:36 +02:00
github-actions[bot] d9ccb389f0 chore: release 1.13.1 2026-05-11 17:34:22 +00:00
github-actions[bot] dc80d1d752 chore: release 1.13.0 2026-04-29 20:40:27 +00:00
ff28a80d7c feat: migration-tool (#269)
* feat: working on migration backup tool

* feat: add migration sequences

* fix: responsive for migration tool

* fix: truncate database name in migration

* fix: filter on databases success for migration tool

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-04-29 22:39:37 +02:00
github-actions[bot] ab55ab349a chore: release 1.12.1 2026-04-24 19:43:11 +00:00
Charles GTEandGitHub a1ec7dbec2 fix: migration error on 2fa (#268) 2026-04-24 21:42:20 +02:00
50 changed files with 9179 additions and 1794 deletions
+6
View File
@@ -1,6 +1,12 @@
# Environment
NODE_ENV=development
# Logging
# Controls minimum log level. Options: debug | info | warn | error
# Set to "warn" or "error" in production to suppress health-check ping noise.
# Use "debug" to see all request logs including agent status pings.
LOG_LEVEL=info
# Database
DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public
+4
View File
@@ -55,3 +55,7 @@ certificates
/playwright/.cache/
/playwright/.auth/
/e2e/local-storage.json
.claude
.codex
/docs
+3 -1
View File
@@ -21,6 +21,8 @@ keywords:
- backups
- postgresql
- mysql
- firebird
- mssql
- mariadb
- devops
- database
@@ -31,5 +33,5 @@ keywords:
- web-ui
- agent
license: Apache-2.0
version: 1.12.0
version: 1.14.1
date-released: '2026-03-02'
+12 -11
View File
@@ -25,6 +25,7 @@
[![MongoDB](https://img.shields.io/badge/-MongoDB-13aa52?logo=mongodb&logoColor=white)](https://www.mongodb.com/)
[![Valkey](https://img.shields.io/badge/Valkey-6284fc?style=flat&logo=Valkey&logoColor=white)](https://valkey.io/)
[![Firebird](https://img.shields.io/badge/Firebird-f55b14?style=flat&logo=Firebird&logoColor=white)](https://firebirdsql.org/)
[![Microsoft SQL Server](https://img.shields.io/badge/Microsoft%20SQL%20Server-CC2927?style=flat&logo=microsoftsqlserver&logoColor=white)](https://www.microsoft.com/en-us/sql-server)
[![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)
@@ -65,17 +66,17 @@ You have 4 ways to install Portabase:
## Supported databases
| Engine | Support | Supported Versions | Restore |
|:-------------------|:-----------|:------------------------------|:--------|
| **PostgreSQL** | ✅ Stable | 12, 13, 14, 15, 16, 17 and 18 | Yes |
| **MySQL** | ✅ Stable | 5.7, 8 and 9 | Yes |
| **MariaDB** | ✅ Stable | 10 and 11 | Yes |
| **MongoDB** | ✅ Stable | 4, 5, 6, 7 and 8 | Yes |
| **SQLite** | ✅ Stable | 3.x | Yes |
| **Redis** | ✅ Stable | 2.8+ | No |
| **Valkey** | ✅ Stable | 7.2+ | No |
| **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes |
| **MSSQL Server** | ❌ Ongoing | - | Yes |
| Engine | Support | Supported Versions | Restore |
|:-------------------|:----------|:------------------------------|:--------|
| **PostgreSQL** | ✅ Stable | 12, 13, 14, 15, 16, 17 and 18 | Yes |
| **MySQL** | ✅ Stable | 5.7, 8 and 9 | Yes |
| **MariaDB** | ✅ Stable | 10 and 11 | Yes |
| **MongoDB** | ✅ Stable | 4, 5, 6, 7 and 8 | Yes |
| **SQLite** | ✅ Stable | 3.x | Yes |
| **Redis** | ✅ Stable | 2.8+ | No |
| **Valkey** | ✅ Stable | 7.2+ | No |
| **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes |
| **MSSQL Server** | ✅ Stable | 2017, 2019, 2022, Azure SQL | Yes |
See the [Database Servers documentation](https://portabase.io/docs/agent/db) for version-specific backup and restore details.
@@ -1,6 +1,7 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {db} from "@/db";
import {logger} from "@/lib/logger";
import {notFound} from "next/navigation";
import {SettingsTabs} from "@/components/wrappers/dashboard/admin/settings/settings-tabs";
import {desc, isNull} from "drizzle-orm";
@@ -8,13 +9,15 @@ import * as drizzleDb from "@/db";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
const log = logger.child({module: "dashboard/admin/settings"});
export default async function RoutePage(props: PageParams<{}>) {
const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"),
});
console.log(settings)
log.debug({settings}, "Settings loaded");
const storageChannels = await db.query.storageChannel.findMany({
with: {
@@ -33,7 +36,7 @@ export default async function RoutePage(props: PageParams<{}>) {
}) as NotificationChannelWith[]
if (!settings || !storageChannels || !notificationChannels ) {
if (!settings || !storageChannels || !notificationChannels) {
notFound()
}
@@ -45,7 +48,8 @@ export default async function RoutePage(props: PageParams<{}>) {
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<SettingsTabs storageChannels={storageChannels} notificationChannels={notificationChannels} settings={settings} />
<SettingsTabs storageChannels={storageChannels} notificationChannels={notificationChannels}
settings={settings}/>
</PageContent>
</Page>
);
@@ -0,0 +1,65 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {notFound} from "next/navigation";
import {getOrganization} from "@/lib/auth/auth";
import {Metadata} from "next";
import {db} from "@/db";
import {MigrationTool} from "@/components/wrappers/dashboard/organization/migration/migration-tool";
export const metadata: Metadata = {
title: "Projects",
};
export default async function RoutePage(props: PageParams<{}>) {
const organization = await getOrganization({});
if (!organization) {
notFound();
}
const projects = await db.query.project.findMany({
where: (project, {eq, and, not}) =>
and(
eq(project.organizationId, organization.id),
not(eq(project.isArchived, true))
),
with: {
organization: true,
databases: {
where: (database, { isNull, not, inArray, and }) =>
and(
isNull(database.deletedAt),
not(inArray(database.dbms, ["valkey", "redis"]))
),
with: {
backups: {
where: (backup, { isNull, eq, and }) =>
and(
isNull(backup.deletedAt),
eq(backup.status, "success")
),
orderBy: (backup, {desc}) => [desc(backup.createdAt)],
limit: 15,
}
}
},
},
});
return (
<Page>
<PageHeader className="flex flex-col items-start justify-between mb-6">
<PageTitle className="mb-2">
Database Migration
</PageTitle>
<p className="text-sm text-muted-foreground">
Import backups from a source project into your target database
</p>
</PageHeader>
<PageContent>
<MigrationTool projects={projects}/>
</PageContent>
</Page>
);
}
-4
View File
@@ -9,8 +9,4 @@ export default async function Index() {
redirect(`/dashboard/home`);
}
redirect("/login");
//Do not delete
// return (
// <Home/>
// );
}
+1 -1
View File
@@ -31,7 +31,7 @@ export async function POST(
) {
try {
const agentId = (await params).agentId
log.info(`Agent ID: ${agentId}`)
log.debug(`Agent ID: ${agentId}`)
const body: Body = await request.json();
const lastContact = new Date();
let message: string
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ success: true });
}
+11 -5
View File
@@ -1,10 +1,10 @@
services:
app:
# build:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: prod
image: portabase/portabase:1.7.1
# build:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: prod
image: portabase/portabase:1
ports:
- '8887:80'
environment:
@@ -17,6 +17,12 @@ services:
db:
condition: service_healthy
container_name: portabase-app-prod
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost/api/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
db:
image: postgres:16-alpine
+10 -1
View File
@@ -12,7 +12,7 @@ services:
- POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme
healthcheck:
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
test: [ "CMD-SHELL", "pg_isready -U devuser -d devdb" ]
interval: 10s
timeout: 5s
retries: 5
@@ -32,5 +32,14 @@ services:
- ./private/uploads/tmp:/data/uploads/tmp
user: "1000:1000"
mailpit:
image: axllent/mailpit:latest
container_name: portabase-mailpit-dev
hostname: mailpit
restart: unless-stopped
ports:
- "8025:8025"
- "1025:1025"
volumes:
postgres-data:
+24
View File
@@ -27,6 +27,30 @@ spec:
volumeMounts:
- name: data
mountPath: /data
startupProbe:
httpGet:
path: /api/health
port: 80
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 12
timeoutSeconds: 5
livenessProbe:
httpGet:
path: /api/health
port: 80
initialDelaySeconds: 0
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/health
port: 80
initialDelaySeconds: 0
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
volumes:
- name: data
persistentVolumeClaim:
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "portabase",
"version": "1.12.0",
"version": "1.14.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
@@ -16,8 +16,8 @@
},
"dependencies": {
"@better-auth/core": "1.6.2",
"@better-auth/passkey": "^1.6.2",
"@better-auth/sso": "^1.6.2",
"@better-auth/passkey": "1.6.2",
"@better-auth/sso": "1.6.2",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -71,7 +71,7 @@
"lucide-react": "^0.553.0",
"minio": "^8.0.7",
"motion": "^12.38.0",
"next": "16.2.1",
"next": "16.2.3",
"next-safe-action": "^7.10.8",
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
@@ -128,7 +128,7 @@
"eslint-plugin-tailwindcss": "^3.18.2",
"framer-motion": "^12.38.0",
"node-pty": "^1.1.0",
"postcss": "^8.5.9",
"postcss": "8.5.10",
"release-it": "^19.2.4",
"tailwindcss": "^4.2.2",
"tsx": "^4.21.0",
@@ -136,5 +136,5 @@
"typescript": "^5.9.3",
"zenstack": "2.14.2"
},
"packageManager": "pnpm@10.33.0"
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499"
}
+2077 -1586
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -1,3 +1,17 @@
allowBuilds:
'@prisma/client': true
'@prisma/engines': true
argon2: true
bcrypt: true
cpu-features: true
esbuild: true
node-pty: true
prisma: true
protobufjs: true
sharp: true
ssh2: true
unrs-resolver: true
zenstack: true
ignoredBuiltDependencies:
- node-pty
onlyBuiltDependencies:
+1
View File
@@ -71,6 +71,7 @@ function checkRouteExists(pathname: string) {
/^\/api\/tus\/hooks\/?$/,
/^\/api\/events\/?$/,
/^\/api\/config\/?$/,
/^\/api\/health\/?$/,
/^\/api\/google\/drive\/callback\/?$/,
];
return routePatterns.some((pattern) => pattern.test(pathname));
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

@@ -7,7 +7,8 @@ import {
Layers,
ChartArea,
ShieldHalf,
Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse, BookOpen
Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse, BookOpen, Hammer,
ChevronsLeftRightEllipsis
} from "lucide-react";
import {SidebarGroupItem, SidebarMenuCustomBase} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar";
import {authClient} from "@/lib/auth/auth-client";
@@ -32,7 +33,17 @@ export const SidebarMenuCustomMain = () => {
const groupContent: SidebarGroupItem["group_content"] = [
{title: "Projects", url: "/projects", icon: Layers, details: true, type: "item"},
{title: "Statistics", url: "/statistics", icon: ChartArea, type: "item"},
{title: "Settings", url: "/settings", icon: Settings, details: true, type: "item"}
{title: "Settings", url: "/settings", icon: Settings, details: true, type: "item"},
{
title: "Tools",
url: "/tools",
icon: Hammer,
details: true,
type: "collapse",
submenu: [
{title: "Migration", url: "/migration", icon: ChevronsLeftRightEllipsis, type: "item"},
]
}
];
@@ -69,8 +80,8 @@ export const SidebarMenuCustomMain = () => {
details: true,
type: "collapse",
submenu: [
{ title: "Channels", url: "/notifications/channels", icon: Blocks, type: "item" },
{ title: "Activity Logs", url: "/notifications/logs", icon: Logs, type: "item" },
{title: "Channels", url: "/notifications/channels", icon: Blocks, type: "item"},
{title: "Activity Logs", url: "/notifications/logs", icon: Logs, type: "item"},
],
},
{
@@ -80,7 +91,7 @@ export const SidebarMenuCustomMain = () => {
details: true,
type: "collapse",
submenu: [
{ title: "Channels", url: "/storages/channels", icon: Blocks, type: "item" },
{title: "Channels", url: "/storages/channels", icon: Blocks, type: "item"},
],
},
{
@@ -91,7 +102,13 @@ export const SidebarMenuCustomMain = () => {
type: "collapse",
submenu: [
{title: "Users", url: "/admin/users", icon: Users, type: "item"},
{title: "Organizations", url: "/admin/organizations", icon: Building, type: "item", details: true},
{
title: "Organizations",
url: "/admin/organizations",
icon: Building,
type: "item",
details: true
},
],
},
{
@@ -105,7 +122,7 @@ export const SidebarMenuCustomMain = () => {
}
items.push(
{
{
label: "Resources",
type: "list",
group_content: [
@@ -0,0 +1,272 @@
"use client"
import {
ArrowRight,
CheckCircle2,
Circle,
Loader2,
Play,
Server,
FolderOpen,
HardDrive,
} from "lucide-react"
import {cn} from "@/lib/utils"
import {ProjectWithDatabasesAndBackups as ProjectWith} from "@/db/schema/06_project";
import {Backup, DatabaseWith} from "@/db/schema/07_database";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
interface MigrationFlowProps {
sourceProject: ProjectWith | null
sourceDatabase: DatabaseWith | null
selectedBackups: Backup[]
targetProject: ProjectWith | null
targetDatabase: DatabaseWith | null
status: MigrationStatus
onStartMigration: () => void
canStart: boolean
}
export type MigrationStatus = "idle" | "migrating" | "completed" | "error"
export function MigrationFlow({
sourceProject,
sourceDatabase,
selectedBackups,
targetProject,
targetDatabase,
status,
onStartMigration,
canStart,
}: MigrationFlowProps) {
const steps = [
{
id: "source-project",
label: "Source Project",
description: "Choose source project",
completed: sourceProject !== null,
active: status === "idle" && !sourceProject,
},
{
id: "source-database",
label: "Source Database",
description: "Select database",
completed: sourceDatabase !== null,
active: status === "idle" && sourceProject !== null && !sourceDatabase,
},
{
id: "backups",
label: "Select Backups",
description: "Choose backups to migrate",
completed: selectedBackups.length > 0,
active: status === "idle" && sourceDatabase !== null && selectedBackups.length === 0,
},
{
id: "target-project",
label: "Target Project",
description: "Choose destination project",
completed: targetProject !== null,
active: status === "idle" && selectedBackups.length > 0 && !targetProject,
},
{
id: "target-database",
label: "Target Database",
description: "Select destination database",
completed: targetDatabase !== null,
active: status === "idle" && targetProject !== null && !targetDatabase,
},
{
id: "migrate",
label: "Migrate Data",
description: "Transfer to target",
completed: status === "completed",
active: status === "migrating",
},
]
return (
<div className="flex h-full flex-col rounded-xl border border-border bg-card">
<div className="border-b border-border px-5 py-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<ArrowRight className="h-5 w-5 text-primary"/>
</div>
<div>
<h2 className="font-semibold text-foreground">Migration Flow</h2>
<p className="text-sm text-muted-foreground">
{status === "idle" && "Configure your migration"}
{status === "migrating" && "Migration in progress..."}
{status === "completed" && "Migration completed!"}
</p>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-5">
<div className="mb-6 flex flex-col gap-3">
{steps.map((step, index) => (
<div key={step.id} className="flex items-start gap-3">
<div className="flex flex-col items-center">
<div
className={cn(
"flex h-7 w-7 items-center justify-center rounded-full border-2 transition-all",
step.completed
? "border-emerald-500 bg-emerald-500 text-white"
: step.active
? "border-primary bg-primary/10 text-primary"
: "border-muted-foreground/30 bg-muted text-muted-foreground"
)}
>
{step.completed ? (
<CheckCircle2 className="h-4 w-4"/>
) : step.active && status === "migrating" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin"/>
) : (
<Circle className="h-3.5 w-3.5"/>
)}
</div>
{index < steps.length - 1 && (
<div
className={cn(
"mt-1 h-6 w-0.5",
step.completed ? "bg-emerald-500" : "bg-border"
)}
/>
)}
</div>
<div className="pt-0.5">
<p
className={cn(
"text-sm font-medium",
step.completed || step.active
? "text-foreground"
: "text-muted-foreground"
)}
>
{step.label}
</p>
<p className="text-xs text-muted-foreground">{step.description}</p>
</div>
</div>
))}
</div>
{(sourceProject || targetProject) && (
<div className="mb-6 rounded-lg border border-border bg-muted/30 p-4">
<h3 className="mb-3 text-sm font-medium text-foreground">
Migration Summary
</h3>
<div className="flex flex-col gap-2.5 text-sm">
{/* Source */}
{sourceProject && (
<div className="flex items-start gap-2">
<FolderOpen className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground"/>
<div className="min-w-0">
<span className="text-muted-foreground">From: </span>
<span className="font-medium text-foreground">
{sourceProject.name}
</span>
{sourceDatabase && (
<span className="text-muted-foreground">
{" "}/ {sourceDatabase.name}
</span>
)}
</div>
</div>
)}
{selectedBackups.length > 0 && (
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground"/>
<span className="text-muted-foreground">Backups:</span>
<span className="font-medium text-foreground">
{selectedBackups.length}
</span>
</div>
)}
{targetProject && (
<div className="flex items-start gap-2">
<Server className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground"/>
<div className="min-w-0">
<span className="text-muted-foreground">To: </span>
<span className="font-medium text-foreground">
{targetProject.name}
</span>
{targetDatabase && (
<span className="text-muted-foreground">
{" "}/ {targetDatabase.name}
</span>
)}
</div>
</div>
)}
</div>
</div>
)}
{status === "completed" && (
<div className="mb-6 rounded-lg border border-emerald-500/20 bg-emerald-500/10 p-4">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-emerald-600"/>
<span className="font-medium text-emerald-600">
Migration completed successfully!
</span>
</div>
</div>
)}
</div>
<div className="border-t border-border p-5">
<ButtonWithLoading
className={cn(
"flex w-full items-center justify-center gap-2 rounded-lg px-4 py-3 font-medium transition-all",
canStart
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "cursor-not-allowed bg-muted text-muted-foreground"
)}
onClick={onStartMigration}
disabled={!canStart}
isPending={status === "migrating"}
size="lg"
type="button"
>
{status === "migrating" ? (
<>
Migrating...
</>
) : status === "completed" ? (
<>
<CheckCircle2 className="h-4 w-4"/>
Completed
</>
) : (
<>
<Play className="h-4 w-4"/>
Start Migration
</>
)}
</ButtonWithLoading>
{!canStart && status === "idle" && (
<p className="mt-2 text-center text-xs text-muted-foreground">
{!sourceProject
? "Select a source project"
: !sourceDatabase
? "Select a source database"
: selectedBackups.length === 0
? "Select at least one backup"
: !targetProject
? "Select a target project"
: !targetDatabase
? "Select a target database"
: "Ready to start"}
</p>
)}
</div>
</div>
)
}
@@ -0,0 +1,172 @@
"use client"
import {useState, useMemo} from "react"
import {ProjectWithDatabasesAndBackups as ProjectWith} from "@/db/schema/06_project"
import {SourcePanel} from "@/components/wrappers/dashboard/organization/migration/source-panel"
import {Backup, DatabaseWith} from "@/db/schema/07_database"
import {MigrationFlow, MigrationStatus} from "@/components/wrappers/dashboard/organization/migration/migration-flow"
import {TargetPanel} from "@/components/wrappers/dashboard/organization/migration/target-panel"
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {migrationAction} from "@/components/wrappers/dashboard/organization/migration/migration.action";
import {useRouter} from "next/navigation";
interface MigrationToolProps {
projects: ProjectWith[]
}
export const MigrationTool = ({projects}: MigrationToolProps) => {
const [sourceProject, setSourceProject] = useState<ProjectWith | null>(null)
const [sourceDatabase, setSourceDatabase] = useState<DatabaseWith | null>(null)
const [selectedBackups, setSelectedBackups] = useState<Backup[]>([])
const router = useRouter()
const [targetProject, setTargetProject] = useState<ProjectWith | null>(null)
const [targetDatabase, setTargetDatabase] = useState<DatabaseWith | null>(null)
const [migrationStatus, setMigrationStatus] = useState<MigrationStatus>("idle")
const [_migrationProgress, setMigrationProgress] = useState(0)
const sourceDbKind = sourceDatabase?.dbms
const isTargetEnabled = useMemo(() => {
return migrationStatus === "idle" &&
sourceDatabase !== null &&
selectedBackups.length > 0
}, [sourceDatabase, migrationStatus, selectedBackups])
const filteredProjects = useMemo(() => {
if (!sourceDbKind) return []
return projects.map((project) => ({
...project,
databases: project.databases.filter((db) => {
const sameKind = db.dbms === sourceDbKind
const notSource = db.id !== sourceDatabase?.id
return sameKind && notSource
}),
}))
}, [projects, sourceDbKind, sourceDatabase])
const handleSelectSourceProject = (project: ProjectWith | null) => {
setSourceProject(project)
setSourceDatabase(null)
setSelectedBackups([])
setTargetProject(null)
setTargetDatabase(null)
}
const handleSelectSourceDatabase = (database: DatabaseWith | null) => {
setSourceDatabase(database)
setSelectedBackups([])
if (targetDatabase && database?.dbms !== targetDatabase.dbms) {
setTargetProject(null)
setTargetDatabase(null)
}
if (targetDatabase && database?.id === targetDatabase.id) {
setTargetProject(null)
setTargetDatabase(null)
}
}
const handleSelectBackup = (backup: Backup) => {
setSelectedBackups((prev) => {
const isSelected = prev.some((b) => b.id === backup.id)
if (isSelected) {
return prev.filter((b) => b.id !== backup.id)
}
return [...prev, backup]
})
}
const handleSelectTargetProject = (project: ProjectWith | null) => {
setTargetProject(project)
setTargetDatabase(null)
}
const handleSelectTargetDatabase = (database: DatabaseWith | null) => {
setTargetDatabase(database)
}
const mutation = useMutation({
mutationFn: async () => {
if (selectedBackups.length === 0 || !targetDatabase) return
setMigrationStatus("migrating")
const result = await migrationAction({
targetDatabaseId: targetDatabase?.id,
backupIds: selectedBackups.map((backup) => backup.id)
})
const inner = result?.data;
console.log(inner)
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
handleReset()
router.refresh();
} else {
toast.error(inner?.actionError?.message);
}
},
});
const handleReset = () => {
setMigrationStatus("idle")
setMigrationProgress(0)
setSelectedBackups([])
setSourceProject(null)
setSourceDatabase(null)
setTargetProject(null)
setTargetDatabase(null)
}
const canStartMigration =
selectedBackups.length > 0 &&
targetDatabase !== null &&
migrationStatus === "idle"
return (
<div className="h-full">
<div className="flex flex-col md:grid md:grid-cols-12 gap-6 h-full">
<div className="col-span-4">
<SourcePanel
projects={projects}
selectedProject={sourceProject}
selectedDatabase={sourceDatabase}
selectedBackups={selectedBackups}
onSelectProject={handleSelectSourceProject}
onSelectDatabase={handleSelectSourceDatabase}
onSelectBackup={handleSelectBackup}
disabled={migrationStatus !== "idle"}
/>
</div>
<div className="col-span-4">
<MigrationFlow
sourceProject={sourceProject}
sourceDatabase={sourceDatabase}
selectedBackups={selectedBackups}
targetProject={targetProject}
targetDatabase={targetDatabase}
status={migrationStatus}
onStartMigration={() => mutation.mutateAsync()}
canStart={canStartMigration}
/>
</div>
<div className="col-span-4 min-h-36">
<TargetPanel
projects={filteredProjects}
selectedProject={targetProject}
selectedDatabase={targetDatabase}
onSelectProject={handleSelectTargetProject}
onSelectDatabase={handleSelectTargetDatabase}
disabled={!isTargetEnabled}
/>
</div>
</div>
</div>
)
}
@@ -0,0 +1,192 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
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 {dispatchStorage} from "@/features/storages/dispatch";
import {v4 as uuidv4} from "uuid";
import {getTodayISODate} from "@/utils/date-formatting";
import {withUpdatedAt} from "@/db/utils";
export const migrationAction = userAction.schema(
z.object({
targetDatabaseId: z.string(),
backupIds: z.array(z.string()),
})
).action(async ({ parsedInput }): Promise<ServerActionResult<{}>> => {
const { targetDatabaseId, backupIds } = parsedInput;
let hasError = false;
try {
const targetDatabase = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, targetDatabaseId),
with: {
project: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true
}
});
if (!targetDatabase) {
return {
success: false,
actionError: {
message: "Unable to find target database",
status: 404,
},
};
}
const backups = await db.query.backup.findMany({
where: inArray(drizzleDb.schemas.backup.id, backupIds),
with: {
database: true,
storages: true
}
});
if (backups.length !== backupIds.length) {
return {
success: false,
actionError: {
message: "Some backups were not found",
status: 400,
},
};
}
await Promise.all(
backups.map(async (backup) => {
const [migratedBackup] = await db
.insert(drizzleDb.schemas.backup)
.values({
status: "ongoing",
databaseId: targetDatabaseId,
fileSize: backup.fileSize,
migrated: true,
})
.returning();
const storageResults = await Promise.allSettled(
backup.storages.map(async (storage) => {
const [backupStorage] = await db
.insert(drizzleDb.schemas.backupStorage)
.values({
backupId: migratedBackup.id,
storageChannelId: storage.storageChannelId,
status: "pending",
})
.returning();
const fileName = `${uuidv4()}.tar.gz`;
const pathTo = `backups/${getTodayISODate()}/${fileName}`;
try {
const result = await dispatchStorage(
{
action: "copy",
data: {
from: storage.path ?? "",
to: pathTo,
},
metadata: {
storageId: storage.storageChannelId,
fileKind: "backups",
},
},
undefined,
storage.storageChannelId
);
if (!result.success) {
hasError = true;
throw new Error("dispatchStorage failed");
}
await db
.update(drizzleDb.schemas.backupStorage)
.set(
withUpdatedAt({
status: "success",
path: pathTo,
size: backup.fileSize,
})
)
.where(eq(drizzleDb.schemas.backupStorage.id, backupStorage.id));
return { success: true };
} catch (error) {
hasError = true;
await db
.update(drizzleDb.schemas.backupStorage)
.set(
withUpdatedAt({
status: "failed",
})
)
.where(eq(drizzleDb.schemas.backupStorage.id, backupStorage.id));
return { success: false };
}
})
);
const failed = storageResults.some(
r => r.status === "rejected" || (r.status === "fulfilled" && !r.value.success)
);
if (failed) hasError = true;
await db
.update(drizzleDb.schemas.backup)
.set(
withUpdatedAt({
status: failed ? "failed" : "success",
})
)
.where(eq(drizzleDb.schemas.backup.id, migratedBackup.id));
})
);
if (hasError) {
return {
success: false,
actionError: {
message: "Migration completed with errors",
status: 500,
},
};
}
return {
success: true,
value: {},
actionSuccess: {
message: "Backups successfully migrated.",
messageParams: {},
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Migration failed unexpectedly",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -0,0 +1,309 @@
"use client"
import {
Database,
HardDrive,
Calendar,
Check,
ChevronLeft,
FolderOpen,
Server,
} from "lucide-react"
import {cn} from "@/lib/utils"
import {ProjectWithDatabasesAndBackups as ProjectWith} from "@/db/schema/06_project";
import {Backup, DatabaseWith} from "@/db/schema/07_database";
import {ScrollArea} from "@/components/ui/scroll-area";
import {formatLocalizedDate} from "@/utils/date-formatting";
import {formatBytes, truncateWords} from "@/utils/text";
type ViewState = "projects" | "databases" | "backups"
interface SourcePanelProps {
projects: ProjectWith[]
selectedProject: ProjectWith | null
selectedDatabase: DatabaseWith | null
selectedBackups: Backup[]
onSelectProject: (project: ProjectWith | null) => void
onSelectDatabase: (database: DatabaseWith | null) => void
onSelectBackup: (backup: Backup) => void
disabled?: boolean
}
export function SourcePanel({
projects,
selectedProject,
selectedDatabase,
selectedBackups,
onSelectProject,
onSelectDatabase,
onSelectBackup,
disabled,
}: SourcePanelProps) {
const currentView: ViewState = selectedDatabase
? "backups"
: selectedProject
? "databases"
: "projects"
const handleBack = () => {
if (currentView === "backups") {
onSelectDatabase(null)
} else if (currentView === "databases") {
onSelectProject(null)
}
}
const getHeaderInfo = () => {
if (currentView === "backups" && selectedDatabase) {
return {
title: selectedDatabase.name,
subtitle: selectedDatabase.backups?.length? `${selectedBackups.length} of ${selectedDatabase.backups.length} backups selected` : "",
icon: <Database className="h-5 w-5 text-primary"/>,
}
}
if (currentView === "databases" && selectedProject) {
return {
title: selectedProject.name,
subtitle: `${selectedProject.databases.length} database${selectedProject.databases.length !== 1 ? "s" : ""} available`,
icon: <FolderOpen className="h-5 w-5 text-primary"/>,
}
}
return {
title: "Source",
subtitle: "Select a project to start",
icon: <FolderOpen className="h-5 w-5 text-primary"/>,
}
}
const headerInfo = getHeaderInfo()
return (
<div className="rounded-xl border border-border bg-card h-full flex flex-col overflow-hidden">
<div className="border-b border-border px-5 py-4">
<div className="flex items-center gap-3">
{currentView !== "projects" ? (
<button
onClick={handleBack}
disabled={disabled}
className="flex h-9 w-9 items-center justify-center rounded-lg bg-secondary transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronLeft className="h-5 w-5 text-secondary-foreground"/>
</button>
) : (
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
{headerInfo.icon}
</div>
)}
<div className="min-w-0 flex-1">
<h2 className="truncate font-semibold text-foreground">
{headerInfo.title}
</h2>
<p className="text-sm text-muted-foreground">{headerInfo.subtitle}</p>
</div>
</div>
{currentView !== "projects" && (
<div className="mt-3 flex items-center gap-1.5 text-xs text-muted-foreground">
<button
onClick={() => {
onSelectDatabase(null)
onSelectProject(null)
}}
disabled={disabled}
className="hover:text-foreground disabled:opacity-50"
>
Projects
</button>
{selectedProject && (
<>
<span>/</span>
<button
onClick={() => onSelectDatabase(null)}
disabled={disabled}
className={cn(
"truncate hover:text-foreground disabled:opacity-50",
currentView === "databases" && "text-foreground"
)}
>
{selectedProject.name}
</button>
</>
)}
{selectedDatabase && (
<>
<span>/</span>
<span className="truncate text-foreground">
{selectedDatabase.name}
</span>
</>
)}
</div>
)}
</div>
<ScrollArea
// Do not remove the radix scroll
className="w-full flex-1 [&>[data-radix-scroll-area-viewport]]:max-h-[calc(100vh-320px)]"
>
<div className="p-4">
{currentView === "projects" && (
<div className="flex flex-col gap-2">
{projects.length === 0 ? (
<div
className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border p-6 text-center">
<p className="text-sm text-muted-foreground">
No projects available
</p>
</div>
) : (
projects.map((project) => (
<button
key={project.id}
onClick={() => onSelectProject(project)}
disabled={disabled}
className={cn(
"group w-full rounded-lg border border-border bg-card p-4 text-left transition-all",
"hover:border-primary/50 hover:bg-accent/50",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
disabled && "cursor-not-allowed opacity-50"
)}
>
<div className="flex items-start gap-3">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<Server className="h-5 w-5 text-muted-foreground"/>
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate font-medium text-foreground">
{truncateWords(project.name, 6)}
</h3>
<p className="mt-2 text-sm text-muted-foreground">
{project.databases.length} database
{project.databases.length !== 1 ? "s" : ""}
</p>
</div>
<ChevronLeft
className="h-5 w-5 rotate-180 text-muted-foreground transition-transform group-hover:translate-x-1"/>
</div>
</button>
))
)}
</div>
)}
{currentView === "databases" && selectedProject && (
<div className="flex flex-col gap-2">
{selectedProject.databases.length === 0 ? (
<div className="py-8 text-center">
<Database className="mx-auto h-10 w-10 text-muted-foreground/50"/>
<p className="mt-2 text-sm text-muted-foreground">
No databases in this project
</p>
</div>
) : (
selectedProject.databases.map((database) => (
<button
key={database.id}
onClick={() => onSelectDatabase(database)}
disabled={disabled}
className={cn(
"group w-full rounded-lg border border-border bg-card p-4 text-left transition-all",
"hover:border-primary/50 hover:bg-accent/50",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
disabled && "cursor-not-allowed opacity-50"
)}
>
<div className="flex items-start gap-3">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<Database className="h-5 w-5 text-muted-foreground"/>
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate font-medium text-foreground">
{truncateWords(database.name, 6)}
</h3>
<p className="mt-0.5 text-sm text-muted-foreground">
{database.dbms}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{database?.backups?.length} backup
{database?.backups?.length !== 1 ? "s" : ""} available
</p>
</div>
<ChevronLeft
className="h-5 w-5 rotate-180 text-muted-foreground transition-transform group-hover:translate-x-1"/>
</div>
</button>
))
)}
</div>
)}
{currentView === "backups" && selectedDatabase && (
<div className="flex flex-col gap-2">
{selectedDatabase?.backups?.length === 0 ? (
<div className="py-8 text-center">
<HardDrive className="mx-auto h-10 w-10 text-muted-foreground/50"/>
<p className="mt-2 text-sm text-muted-foreground">
No backups available for this database
</p>
</div>
) : (
selectedDatabase?.backups?.map((backup) => {
const isSelected = selectedBackups.some((b) => b.id === backup.id)
return (
<button
key={backup.id}
onClick={() => onSelectBackup(backup)}
disabled={disabled}
className={cn(
"group relative w-full rounded-lg border p-4 text-left transition-all",
"hover:border-primary/50 hover:bg-accent/50",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
isSelected
? "border-primary bg-primary/5"
: "border-border bg-card",
disabled && "cursor-not-allowed opacity-50"
)}
>
<div
className={cn(
"absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full border-2 transition-all",
isSelected
? "border-primary bg-primary"
: "border-muted-foreground/30 bg-transparent"
)}
>
{isSelected && (
<Check className="h-3 w-3 text-primary-foreground"/>
)}
</div>
<div className="mb-3 pr-8">
<h3 className="font-medium text-foreground">{backup.id}</h3>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<HardDrive className="h-3.5 w-3.5"/>
<span>{formatBytes(backup.fileSize)}</span>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Calendar className="h-3.5 w-3.5"/>
<span>{ formatLocalizedDate(backup.createdAt)}</span>
</div>
</div>
</button>
)
})
)}
</div>
)}
</div>
</ScrollArea>
</div>
)
}
@@ -0,0 +1,214 @@
"use client"
import {
Database,
Check,
ChevronLeft,
FolderOpen,
Server,
} from "lucide-react"
import {cn} from "@/lib/utils"
import {ProjectWithDatabasesAndBackups as ProjectWith} from "@/db/schema/06_project";
import {DatabaseWith} from "@/db/schema/07_database";
import {ScrollArea} from "@/components/ui/scroll-area";
import {truncateWords} from "@/utils/text";
type ViewState = "projects" | "databases"
interface TargetPanelProps {
projects: ProjectWith[]
selectedProject: ProjectWith | null
selectedDatabase: DatabaseWith | null
onSelectProject: (project: ProjectWith | null) => void
onSelectDatabase: (database: DatabaseWith | null) => void
disabled?: boolean
}
export function TargetPanel({
projects,
selectedProject,
selectedDatabase,
onSelectProject,
onSelectDatabase,
disabled,
}: TargetPanelProps) {
const currentView: ViewState = selectedProject ? "databases" : "projects"
const handleBack = () => {
onSelectDatabase(null)
onSelectProject(null)
}
const getHeaderInfo = () => {
if (selectedProject) {
return {
title: selectedProject.name,
subtitle: selectedDatabase
? selectedDatabase.name
: "Select a target database",
icon: <FolderOpen className="h-5 w-5 text-primary"/>,
}
}
return {
title: "Target",
subtitle: "Select destination project",
icon: <Server className="h-5 w-5 text-primary"/>,
}
}
const headerInfo = getHeaderInfo()
return (
<div className="rounded-xl border border-border bg-card h-full flex flex-col overflow-hidden">
<div className="border-b border-border px-5 py-4">
<div className="flex items-center gap-3">
{currentView !== "projects" ? (
<button
onClick={handleBack}
disabled={disabled}
className="flex h-9 w-9 items-center justify-center rounded-lg bg-secondary transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronLeft className="h-5 w-5 text-secondary-foreground"/>
</button>
) : (
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
{headerInfo.icon}
</div>
)}
<div className="min-w-0 flex-1">
<h2 className="truncate font-semibold text-foreground">
{headerInfo.title}
</h2>
<p className="text-sm text-muted-foreground">{headerInfo.subtitle}</p>
</div>
</div>
{currentView !== "projects" && (
<div className="mt-3 flex items-center gap-1.5 text-xs text-muted-foreground">
<button
onClick={handleBack}
disabled={disabled}
className="hover:text-foreground disabled:opacity-50"
>
Projects
</button>
{selectedProject && (
<>
<span>/</span>
<span className="truncate text-foreground">
{selectedProject.name}
</span>
</>
)}
</div>
)}
</div>
<ScrollArea
// Do not remove the radix scroll
className="w-full flex-1 [&>[data-radix-scroll-area-viewport]]:max-h-[calc(100vh-320px)]"
>
<div className="p-4">
{currentView === "projects" && (
<div className="flex flex-col gap-2">
{projects.map((project) => (
<button
key={project.id}
onClick={() => onSelectProject(project)}
disabled={disabled}
className={cn(
"group w-full rounded-lg border border-border bg-card p-4 text-left transition-all",
"hover:border-primary/50 hover:bg-accent/50",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
disabled && "cursor-not-allowed opacity-50"
)}
>
<div className="flex items-start gap-3">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<Server className="h-5 w-5 text-muted-foreground"/>
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate font-medium text-foreground">
{truncateWords(project.name, 6)}
</h3>
<div className="mt-1 flex items-center gap-2">
</div>
<p className="mt-2 text-sm text-muted-foreground">
{project.databases.length} database
{project.databases.length !== 1 ? "s" : ""}
</p>
</div>
<ChevronLeft
className="h-5 w-5 rotate-180 text-muted-foreground transition-transform group-hover:translate-x-1"/>
</div>
</button>
))}
</div>
)}
{currentView === "databases" && selectedProject && (
<div className="flex flex-col gap-2">
{selectedProject.databases.length === 0 ? (
<div className="py-8 text-center">
<Database className="mx-auto h-10 w-10 text-muted-foreground/50"/>
<p className="mt-2 text-sm text-muted-foreground">
No databases in this project
</p>
</div>
) : (
selectedProject.databases.map((database) => {
const isSelected = selectedDatabase?.id === database.id
return (
<button
key={database.id}
onClick={() => onSelectDatabase(database)}
disabled={disabled}
className={cn(
"group relative w-full rounded-lg border p-4 text-left transition-all",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
isSelected
? "border-primary bg-primary/5"
: "border-border bg-card",
(disabled) && "cursor-not-allowed opacity-50"
)}
>
<div
className={cn(
"absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full border-2 transition-all",
isSelected
? "border-primary bg-primary"
: "border-muted-foreground/30 bg-transparent"
)}
>
{isSelected && (
<Check className="h-3 w-3 text-primary-foreground"/>
)}
</div>
<div className="flex items-start gap-3 pr-8">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<Database className="h-5 w-5 text-muted-foreground"/>
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate font-medium text-foreground">
{database.name}
</h3>
<p className="mt-0.5 text-sm text-muted-foreground">
{database.dbms}
</p>
</div>
</div>
</button>
)
})
)}
</div>
)}
</div>
</ScrollArea>
</div>
)
}
@@ -1,13 +1,16 @@
"use server";
import {userAction} from "@/lib/safe-actions/actions";
import { logger } from "@/lib/logger";
import {z} from "zod";
import {v4 as uuidv4} from "uuid";
import {ServerActionResult} from "@/types/action-type";
import {and, eq, inArray} from "drizzle-orm";
import {eq, inArray} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
const log = logger.child({ module: "dashboard/delete-project.action" });
export const deleteProjectAction = userAction.schema(z.string()).action(async ({parsedInput}): Promise<ServerActionResult<typeof drizzleDb.schemas.project.$inferSelect>> => {
try {
const uuid = uuidv4();
@@ -51,7 +54,7 @@ export const deleteProjectAction = userAction.schema(z.string()).action(async ({
},
};
} catch (error) {
console.log(error);
log.error({ error }, "Failed to archive project");
return {
success: false,
actionError: {
@@ -1,165 +1,171 @@
"use client";
import { DatabaseBackupActionsModal } from "@/components/wrappers/dashboard/database/backup/actions/backup-actions-modal";
import { DatabaseTabs } from "@/components/wrappers/dashboard/projects/database/database-tabs";
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 } from "@tanstack/react-query";
import { getDatabaseDataAction } from "@/components/wrappers/dashboard/database/backup/actions/get-data.action";
import {DatabaseBackupActionsModal} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions-modal";
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs";
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} from "@tanstack/react-query";
import {getDatabaseDataAction} from "@/components/wrappers/dashboard/database/backup/actions/get-data.action";
import {
PageContent,
PageDescription,
PageTitle,
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";
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";
import {HealthModal} from "@/components/wrappers/dashboard/database/health/health-modal";
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
import {Badge} from "@/components/ui/badge";
export type DatabaseContentProps = {
settings: Setting;
backups: BackupWith[];
restorations: Restoration[];
isAlreadyRestore: boolean;
database: DatabaseWith;
activeMember: MemberWithUser;
totalBackups: number;
availableBackups: number;
successRate: number | null;
organizationId: string;
activeOrganizationChannels: any[];
activeOrganizationStorageChannels: any[];
databaseHealthLogs: HealthcheckLog[]
settings: Setting;
backups: BackupWith[];
restorations: Restoration[];
isAlreadyRestore: boolean;
database: DatabaseWith;
activeMember: MemberWithUser;
totalBackups: number;
availableBackups: number;
successRate: number | null;
organizationId: string;
activeOrganizationChannels: any[];
activeOrganizationStorageChannels: any[];
databaseHealthLogs: HealthcheckLog[]
};
export const DatabaseContent = (props: DatabaseContentProps) => {
const {} = useBackupModal();
const {} = useBackupModal();
const { data } = useQuery({
queryKey: ["database-data", props.database.id],
queryFn: async () => {
const result = await getDatabaseDataAction({
databaseId: props.database.id,
});
return result?.data;
},
initialData: {
// TODO : to be patched
// @ts-ignore
database: {
...props.database,
project: props.database.project ?? null,
},
backups: props.backups,
restorations: props.restorations,
activeOrganizationChannels: props.activeOrganizationChannels,
activeOrganizationStorageChannels:
props.activeOrganizationStorageChannels,
stats: {
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,
},
health: props.databaseHealthLogs
},
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,
},
health: props.databaseHealthLogs
},
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 isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const isAlreadyBackup = backups.some(
(b) => b.status === "waiting" || b.status === "ongoing",
);
const isMember = props.activeMember.role === "member";
const isMember = props.activeMember.role === "member";
return (
<>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(database.name)}
</div>
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<RetentionPolicySheet database={database} />
<CronButton database={database} />
<ChannelPoliciesModal
database={database}
kind={"notification"}
icon={<Megaphone />}
channels={activeOrganizationChannels}
organizationId={props.organizationId}
/>
<ChannelPoliciesModal
database={database}
icon={<HardDrive />}
kind={"storage"}
channels={activeOrganizationStorageChannels}
organizationId={props.organizationId}
/>
<ImportModal database={database} />
<HealthModal database={database} healthLogs={data?.health ?? []}/>
</div>
return (
<>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="flex min-w-full md:min-w-fit justify-between gap-2 items-center ">
{capitalizeFirstLetter(database.name)}
<div className=" flex items-center justify-center">
<Badge variant="outline" className="bg-orange-400/10 border-orange-600/50 text-orange-600">
{database.dbms}
</Badge>
</div>
</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}/>
<HealthModal database={database} healthLogs={data?.health ?? []}/>
</div>
<div className="flex items-center gap-2">
<BackupButton
disable={isAlreadyBackup || !database.lastContact}
databaseId={database.id}
/>
</div>
<div className="flex items-center gap-2">
<BackupButton
disable={isAlreadyBackup || !database.lastContact}
databaseId={database.id}
/>
</div>
</div>
)}
</PageTitle>
</div>
)}
</PageTitle>
</div>
{database.description && (
<PageDescription className="mt-5 sm:mt-0">
{database.description}
</PageDescription>
)}
{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>
</>
);
<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>
</>
);
};
+4 -1
View File
@@ -1 +1,4 @@
ALTER TABLE "two_factor" ADD COLUMN "verified" boolean NOT NULL;
ALTER TABLE "two_factor" ADD COLUMN IF NOT EXISTS "verified" boolean DEFAULT false;
UPDATE "two_factor" SET "verified" = true WHERE "verified" = false;
ALTER TABLE "two_factor" ALTER COLUMN "verified" SET NOT NULL;
ALTER TABLE "two_factor" ALTER COLUMN "verified" DROP DEFAULT;
@@ -0,0 +1 @@
ALTER TABLE "backups" ADD COLUMN "migrated" boolean DEFAULT false;
@@ -0,0 +1 @@
ALTER TYPE "public"."dbms_status" ADD VALUE 'mssql';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -372,6 +372,20 @@
"when": 1775803959723,
"tag": "0052_cute_punisher",
"breakpoints": true
},
{
"idx": 53,
"version": "7",
"when": 1777204042208,
"tag": "0053_lyrical_union_jack",
"breakpoints": true
},
{
"idx": 54,
"version": "7",
"when": 1778523056918,
"tag": "0054_hesitant_darkhawk",
"breakpoints": true
}
]
}
+7 -1
View File
@@ -3,7 +3,7 @@ import {relations} from "drizzle-orm";
import {Organization, organization} from "./03_organization";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
import {Database, database} from "./07_database";
import {Database, database, DatabaseWith} from "./07_database";
import {timestamps} from "@/db/schema/00_common";
export const project = pgTable("projects", {
@@ -32,3 +32,9 @@ export type ProjectWith = Project & {
databases: Database[];
organization: Organization;
};
export type ProjectWithDatabasesAndBackups = Project & {
databases: DatabaseWith[];
organization: Organization;
};
+1
View File
@@ -42,6 +42,7 @@ export const backup = pgTable(
.notNull()
.references(() => database.id, {onDelete: "cascade"}),
imported: boolean('imported').default(false),
migrated: boolean('migrated').default(false),
...timestamps
},
);
+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", "mysql", "mariadb", "mongodb", "sqlite", "redis", "valkey", "firebird"]);
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql", "mariadb", "mongodb", "sqlite", "redis", "valkey", "firebird", "mssql"]);
export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
+2
View File
@@ -10,6 +10,7 @@ export const env = createEnv({
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
NODE_ENV: z.enum(["development", "production"]).optional(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
DATABASE_URL: z.string().url().optional(),
@@ -100,6 +101,7 @@ export const env = createEnv({
},
runtimeEnv: {
NEXT_PUBLIC_PROJECT_VERSION: version || "Unknown Version",
LOG_LEVEL: process.env.LOG_LEVEL,
PROJECT_NAME: process.env.PROJECT_NAME,
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
@@ -60,6 +60,7 @@ export function backupColumns(
cell: ({row}) => {
const reference = row.original.id
const isImported = row.original.imported
const isMigrated = row.original.migrated
return (
<div className="flex items-center space-x-2">
<span>{reference}</span>
@@ -68,6 +69,11 @@ export function backupColumns(
Imported
</BadgeC>
)}
{isMigrated && (
<BadgeC variant="outline" className="bg-blue-400/10 border-blue-600/50 text-blue-600">
Migrated
</BadgeC>
)}
</div>
)
},
+4 -1
View File
@@ -11,6 +11,9 @@ import {StorageChannel} from "@/db/schema/12_storage-channel";
import {
StorageChannelFormType
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema";
import { logger } from "@/lib/logger";
const log = logger.child({ module: "features/storages/dispatch" });
export async function dispatchStorage(
@@ -103,7 +106,7 @@ export async function dispatchStorage(
input,
);
console.log(dispatchResult);
log.debug({ result: dispatchResult }, "Storage dispatch result");
return dispatchResult;
} catch (err: any) {
@@ -1,5 +1,12 @@
"use server"
import {StorageDeleteInput, StorageGetInput, StorageMetaData, StorageResult, StorageUploadInput} from '../../types';
import {
StorageCopyInput,
StorageDeleteInput,
StorageGetInput,
StorageMetaData,
StorageResult,
StorageUploadInput
} from '../../types';
import {GoogleDriveConfig} from "@/features/storages/providers/google-drive/types";
import {
ensureFolderPath,
@@ -144,4 +151,71 @@ export async function pingGoogleDrive(config: GoogleDriveConfig): Promise<Storag
} catch (err: any) {
return {success: false, provider: "google-drive", response: err.message};
}
}
export async function copyGoogleDrive(
config: GoogleDriveConfig,
input: {
data: StorageCopyInput,
metadata?: StorageMetaData;
},
): Promise<StorageResult> {
const client = await getGoogleDriveClient(config);
const sourceFileId = await resolveFilePath(
client,
input.data.from,
config.folderId,
);
if (!sourceFileId) {
return {
success: false,
provider: "google-drive",
error: "Source file not found",
};
}
const fullPath = input.data.to;
const parts = fullPath.split("/").filter(Boolean);
const fileName = parts.pop()!;
const folderPath = parts.join("/");
const folderId = folderPath
? await ensureFolderPath(client, folderPath, config.folderId)
: config.folderId;
try {
const copied = await client.files.copy({
fileId: sourceFileId,
requestBody: {
name: fileName,
parents: [folderId],
},
fields: "id",
supportsAllDrives: true,
});
const newFileId = copied.data.id;
if (!newFileId) {
return {
success: false,
provider: "google-drive",
error: "Copy failed (no file id returned)",
};
}
return {
success: true,
provider: "google-drive",
};
} catch (err: any) {
return {
success: false,
provider: "google-drive",
error: err.message || "Copy failed",
};
}
}
+7 -2
View File
@@ -4,9 +4,10 @@ import {
StorageResult,
} from '../types';
import {uploadLocal, getLocal, deleteLocal, pingLocal} from './local';
import {deleteS3, getS3, pingS3, uploadS3} from "@/features/storages/providers/s3";
import {uploadLocal, getLocal, deleteLocal, pingLocal, copyLocal} from './local';
import {copyS3, deleteS3, getS3, pingS3, uploadS3} from "@/features/storages/providers/s3";
import {
copyGoogleDrive,
deleteGoogleDrive,
getGoogleDrive,
pingGoogleDrive,
@@ -18,6 +19,7 @@ type ProviderHandler = {
get: (config: any, input: StorageInput & { action: 'get' }) => Promise<StorageResult>;
delete: (config: any, input: StorageInput & { action: 'delete' }) => Promise<StorageResult>;
ping: (config: any, input: { action: 'ping' }) => Promise<StorageResult>;
copy: (config: any, input: StorageInput & { action: 'copy' }) => Promise<StorageResult>;
};
const handlers: Record<StorageProviderKind, ProviderHandler> = {
@@ -26,18 +28,21 @@ const handlers: Record<StorageProviderKind, ProviderHandler> = {
get: getLocal,
delete: deleteLocal,
ping: pingLocal,
copy: copyLocal,
},
s3: {
upload: uploadS3,
get: getS3,
delete: deleteS3,
ping: pingS3,
copy: copyS3
},
"google-drive": {
upload: uploadGoogleDrive,
get: getGoogleDrive,
delete: deleteGoogleDrive,
ping: pingGoogleDrive,
copy: copyGoogleDrive,
}
};
+57 -1
View File
@@ -2,6 +2,7 @@
import { mkdir, unlink } from "fs/promises";
import path from "path";
import {
StorageCopyInput,
StorageDeleteInput,
StorageGetInput,
StorageMetaData,
@@ -13,7 +14,7 @@ import { generateFileUrl } from "@/features/storages/helpers";
import { Readable } from "node:stream";
import { env } from "@/env.mjs";
const BASE_DIR = path.join(env.PRIVATE_PATH, "/uploads");
const BASE_DIR = path.join(env.PRIVATE_PATH!, "/uploads");
export async function uploadLocal(
config: { baseDir?: string },
@@ -159,3 +160,58 @@ export async function pingLocal(config: {
response: "Local storage OK",
};
}
export async function copyLocal(
config: { baseDir?: string },
input: {
data: StorageCopyInput,
metadata?: StorageMetaData;
},
): Promise<StorageResult> {
const base = config.baseDir
? path.join(process.cwd(), config.baseDir ?? "")
: BASE_DIR;
const sourcePath = path.join(base, input.data.from);
const destinationPath = path.join(base, input.data.to);
const dir = path.dirname(destinationPath);
await mkdir(dir, { recursive: true });
if (!fs.existsSync(sourcePath)) {
return {
success: false,
provider: "local",
error: "Source file not found",
};
}
try {
await new Promise<void>((resolve, reject) => {
const readStream = fs.createReadStream(sourcePath);
const writeStream = fs.createWriteStream(destinationPath);
readStream.on("error", reject);
writeStream.on("error", reject);
writeStream.on("finish", resolve);
readStream.pipe(writeStream);
});
return {
success: true,
provider: "local",
};
} catch (err: any) {
try {
await unlink(destinationPath);
} catch {}
return {
success: false,
provider: "local",
error: err.message || "Copy failed",
};
}
}
+41 -1
View File
@@ -1,5 +1,12 @@
import * as Minio from "minio";
import {StorageDeleteInput, StorageGetInput, StorageMetaData, StorageResult, StorageUploadInput} from "../types";
import {
StorageCopyInput,
StorageDeleteInput,
StorageGetInput,
StorageMetaData,
StorageResult,
StorageUploadInput
} from "../types";
import {Readable} from "node:stream";
type S3Config = {
@@ -132,3 +139,36 @@ export async function pingS3(config: S3Config): Promise<StorageResult> {
};
}
}
export async function copyS3(
config: S3Config,
input: {
data: StorageCopyInput,
},
): Promise<StorageResult> {
const client = await getS3Client(config);
await ensureBucket(config);
const sourceKey = `${BASE_DIR}${input.data.from}`;
const destinationKey = `${BASE_DIR}${input.data.to}`;
try {
await client.copyObject(
config.bucketName,
destinationKey,
`/${config.bucketName}/${sourceKey}`
);
return {
success: true,
provider: "s3",
};
} catch (err: any) {
return {
success: false,
provider: "s3",
error: err.message,
};
}
}
+8 -1
View File
@@ -38,11 +38,18 @@ export interface StorageDeleteInput {
path: string;
}
export interface StorageCopyInput {
from: string;
to: string;
}
export type StorageInput =
| { action: 'upload'; data: StorageUploadInput, metadata?: StorageMetaData }
| { action: 'get'; data: StorageGetInput, metadata: StorageMetaData }
| { action: 'delete'; data: StorageDeleteInput, metadata?: StorageMetaData }
| { action: 'ping'; };
| { action: 'ping'; }
| { action: 'copy'; data: StorageCopyInput, metadata?: StorageMetaData };
export interface StorageResult {
success: boolean;
+4 -3
View File
@@ -1,5 +1,6 @@
"use server";
import {userAction} from "@/lib/safe-actions/actions";
import {logger} from "@/lib/logger";
import {z} from "zod";
import {v4 as uuidv4} from "uuid";
import {db} from "@/db";
@@ -10,6 +11,7 @@ import {dispatchStorage} from "@/features/storages/dispatch";
import {StorageInput} from "@/features/storages/types";
import sharp from "sharp";
const log = logger.child({module: "features/upload/upload.action"});
export const uploadUserImageAction = userAction.schema(
z.instanceof(FormData)
@@ -27,7 +29,7 @@ export const uploadUserImageAction = userAction.schema(
const isWebp = fileFormat === "webp";
const compressedBuffer = await sharp(buffer)
.resize({ width: 1024 })
.resize({width: 1024})
.toFormat(isPng ? "png" : isWebp ? "webp" : "jpeg", {
quality: 80,
compressionLevel: isPng ? 9 : undefined
@@ -35,7 +37,6 @@ export const uploadUserImageAction = userAction.schema(
.toBuffer();
const settings = await db.query.setting.findFirst({
where: eq(drizzleDb.schemas.setting.name, "system"),
with: {
@@ -69,7 +70,7 @@ export const uploadUserImageAction = userAction.schema(
}
const result = await dispatchStorage(input, undefined, settings.storageChannel.id);
console.log(result);
log.debug({result}, "Upload dispatch result");
if (!result.success) {
return {
success: false,
+6 -6
View File
@@ -22,7 +22,9 @@ import {passkey} from "@better-auth/passkey";
import {getOidcProviders} from "./oidc";
import {APIError} from "better-auth/api";
import {getOAuthProviders} from "./oauth";
import { logger } from "@/lib/logger";
const log = logger.child({ module: "lib/auth" });
const oidcProviders = getOidcProviders();
@@ -438,9 +440,7 @@ export const auth = betterAuth({
new Date(user.createdAt).getTime();
if (createdAtDiff < 5000) {
console.log(
`Skipping new login email for freshly created user ${user.email}`,
);
log.debug(`Skipping new login email for freshly created user ${user.email}`);
return;
}
@@ -471,12 +471,12 @@ export const auth = betterAuth({
),
});
} catch (error) {
console.log("sendEmail failed", {
log.error({
userId: user.id,
details: "Please Check your env variables or system config",
email: user.email,
error,
});
}, "sendEmail failed");
}
(await auth.$context).internalAdapter.updateUser(user.id, {
@@ -784,7 +784,7 @@ export const getActiveMember = async () => {
return member as MemberWithUser;
} catch (e) {
console.log("err", e);
log.error({ error: e }, "Auth error");
}
};
+5 -2
View File
@@ -1,6 +1,9 @@
import pino, { type Logger } from "pino";
import {env} from "@/env.mjs";
const isProd = process.env.NODE_ENV === "production";
const isProd = env.NODE_ENV === "production";
const defaultLevel = isProd ? "info" : "debug";
const level = (env.LOG_LEVEL ?? defaultLevel) as pino.Level;
function getLocalTimestamp() {
const date = new Date();
@@ -19,7 +22,7 @@ function getLocalTimestamp() {
}
export const logger: Logger = pino({
level: isProd ? "info" : "debug",
level,
base: null,
...(isProd
+2 -2
View File
@@ -14,7 +14,7 @@ export const backupCleanTask = async () => {
eq(drizzleDb.schemas.backup.status, "ongoing")
)
});
log.info(`Backups to clean: ${backups.length}`);
log.debug(`Backups to clean: ${backups.length}`);
for (const backup of backups) {
await db.update(drizzleDb.schemas.backup).set(withUpdatedAt({
@@ -24,7 +24,7 @@ export const backupCleanTask = async () => {
}
} catch (e: any) {
log.info({name: "backupCleanTask", error: e},`Backup cleanup failed`);
log.error({name: "backupCleanTask", error: e},`Backup cleanup failed`);
throw e;
}
};
+1 -1
View File
@@ -22,7 +22,7 @@ export const retentionJob = cron.schedule(env.RETENTION_CRON, async () => {
export const cleaningJob = cron.schedule("* * * * *", async () => {
try {
log.info({ job: "cron", action: "start", name: "cleaningJob" }, "Cleaning Job started");
log.debug({ job: "cron", action: "start", name: "cleaningJob" }, "Cleaning Job started");
await backupCleanTask();
} catch (err) {
log.error({ job: "cron", name: "cleaningJob", error: err }, "Cleaning Job Error");
+15 -1
View File
@@ -1,8 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';
const LOG_LEVELS = ["debug", "info", "warn", "error"] as const;
type LogLevel = typeof LOG_LEVELS[number];
const raw = (process.env.LOG_LEVEL ?? "info") as LogLevel;
const _idx = LOG_LEVELS.indexOf(raw);
const LEVEL_INDEX = _idx === -1 ? 1 : _idx;
const STATUS_PATH_RE = /\/api\/agent\/[^/]+\/status\/?$/;
export function loggingMiddleware(request: NextRequest) {
if (request.url.includes('/api')) {
console.log(`[API] Received ${request.method} request : ${request.url} at ${new Date()}`);
const isStatusPing = STATUS_PATH_RE.test(request.nextUrl.pathname);
// Status pings are debug-level; all other API calls are info-level.
const requiredIndex = isStatusPing ? 0 : 1;
if (LEVEL_INDEX <= requiredIndex) {
console.log(`[API] Received ${request.method} request : ${request.url} at ${new Date()}`);
}
}
return NextResponse.next();
}
+4
View File
@@ -78,6 +78,10 @@ export function getFileHeadersBasedOnDbms(dbType: string): Record<string, string
return {
"application/octet-stream": [".backup"],
};
case "mssql":
return {
"application/octet-stream": [".bacpac"],
};
default:
throw new Error(`Unsupported database type: ${dbType}`);
+8
View File
@@ -47,3 +47,11 @@ export function formatDayOnly(date: Date) {
}).format(date);
}
export function getTodayISODate() {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}