mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e91ef4c142 | ||
|
|
9b504ccd68 | ||
|
|
dd3f4411cf | ||
|
|
0ab94b6221 | ||
|
|
8477889cea | ||
|
|
2eb4a7a0fe | ||
|
|
1f68adda90 | ||
|
|
ffba2c56a3 | ||
|
|
02be78a26e | ||
|
|
711d4613ed | ||
|
|
a5fe5225e1 | ||
|
|
b752d9437c | ||
|
|
18df92e048 | ||
|
|
4e66b8ed04 | ||
|
|
4f14d16452 | ||
|
|
592588ef76 | ||
|
|
3fec7c3392 | ||
|
|
2335caefd7 | ||
|
|
194eedeaa8 | ||
|
|
ecd09aedb9 | ||
|
|
b5350b5fe3 | ||
|
|
ceaafd5531 | ||
|
|
654accad4a | ||
|
|
173c7894b4 | ||
|
|
1ab51d15f9 | ||
|
|
ddce51d2d3 | ||
|
|
fc9fbf4357 | ||
|
|
5e8cd7e0e6 | ||
|
|
1eb5fbb983 | ||
|
|
d9ccb389f0 | ||
|
|
dc80d1d752 | ||
|
|
ff28a80d7c | ||
|
|
ab55ab349a | ||
|
|
a1ec7dbec2 |
@@ -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
|
||||
|
||||
|
||||
@@ -55,3 +55,7 @@ certificates
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
/e2e/local-storage.json
|
||||
|
||||
.claude
|
||||
.codex
|
||||
/docs
|
||||
|
||||
+3
-1
@@ -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'
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
[](https://www.mongodb.com/)
|
||||
[](https://valkey.io/)
|
||||
[](https://firebirdsql.org/)
|
||||
[](https://www.microsoft.com/en-us/sql-server)
|
||||
|
||||
[](https://github.com/Portabase/portabase)
|
||||
[](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>
|
||||
);
|
||||
}
|
||||
@@ -9,8 +9,4 @@ export default async function Index() {
|
||||
redirect(`/dashboard/home`);
|
||||
}
|
||||
redirect("/login");
|
||||
//Do not delete
|
||||
// return (
|
||||
// <Home/>
|
||||
// );
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
+11
-5
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+2077
-1586
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+5
-2
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user