mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e4a430049 | ||
|
|
47d78943d8 | ||
|
|
141abbae9d | ||
|
|
278ac6a17e | ||
|
|
4c63408094 | ||
|
|
adda02e91f | ||
|
|
4fd3125bf9 | ||
|
|
2ef7f7929d | ||
|
|
666d677349 | ||
|
|
1afc6b8c62 | ||
|
|
9f15b49345 | ||
|
|
6d19fb8833 | ||
|
|
df692c4729 | ||
|
|
cd72cfbff4 | ||
|
|
20636bf6f2 | ||
|
|
8f2d6f0b01 | ||
|
|
64b3bf1b16 | ||
|
|
dc4190aa1b | ||
|
|
6efcb55638 | ||
|
|
6b89cdfa1d | ||
|
|
693ef6ec3e | ||
|
|
de33dea50d | ||
|
|
d5e453c390 | ||
|
|
ff65590c97 | ||
|
|
503d5469d4 | ||
|
|
96c51a08c6 | ||
|
|
fe1d077754 | ||
|
|
f2acc103a7 | ||
|
|
c073a30d20 | ||
|
|
bdc863de1f | ||
|
|
97e2c21d52 | ||
|
|
60f57a734d | ||
|
|
6952ac5f48 | ||
|
|
45acc781f2 | ||
|
|
9c2359e87b | ||
|
|
8dcb406790 | ||
|
|
a3d0a6e1df | ||
|
|
8edf0ca742 | ||
|
|
568d9612c5 | ||
|
|
6548945b2f | ||
|
|
4b1d0aba74 | ||
|
|
c726346258 | ||
|
|
0677f14ac5 | ||
|
|
d346f5cdc4 | ||
|
|
1ce08448ec | ||
|
|
77fb1095f1 | ||
|
|
29dd597c7a |
+1
-1
@@ -33,5 +33,5 @@ keywords:
|
|||||||
- web-ui
|
- web-ui
|
||||||
- agent
|
- agent
|
||||||
license: Apache-2.0
|
license: Apache-2.0
|
||||||
version: 1.20.6
|
version: 1.22.3
|
||||||
date-released: '2026-03-02'
|
date-released: '2026-03-02'
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ You have 5 ways to install Portabase:
|
|||||||
| **Valkey** | ✅ Stable | 7.2+ | No |
|
| **Valkey** | ✅ Stable | 7.2+ | No |
|
||||||
| **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes |
|
| **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes |
|
||||||
| **MSSQL Server** | ✅ Stable | 2017, 2019, 2022, Azure SQL | Yes |
|
| **MSSQL Server** | ✅ Stable | 2017, 2019, 2022, Azure SQL | Yes |
|
||||||
|
| **Docker Volume** | ✅ Stable | Docker Engine 20.10+ | Yes |
|
||||||
|
|
||||||
See the [Database Servers documentation](https://portabase.io/docs/agent/db) for version-specific backup and restore details.
|
See the [Database Servers documentation](https://portabase.io/docs/agent/db) for version-specific backup and restore details.
|
||||||
|
|
||||||
@@ -111,5 +112,3 @@ Distributed under the Apache License. See `LICENSE.txt` for more details.
|
|||||||
[ShadcnUI-url]: https://ui.shadcn.com/
|
[ShadcnUI-url]: https://ui.shadcn.com/
|
||||||
|
|
||||||
[Docker-url]: https://www.docker.com/
|
[Docker-url]: https://www.docker.com/
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export default async function RoutePage(
|
|||||||
</PageDescription>
|
</PageDescription>
|
||||||
)}
|
)}
|
||||||
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
||||||
<AgentContentPage agent={agent} edgeKey={edgeKey} />
|
<AgentContentPage agent={agent} edgeKey={edgeKey} canDeleteDatabases={true} />
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
|||||||
+22
-31
@@ -2,7 +2,7 @@ import { PageParams } from "@/types/next";
|
|||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { Page } from "@/features/layout/components/page";
|
import { Page } from "@/features/layout/components/page";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { eq, and, inArray } from "drizzle-orm";
|
import { eq, and, inArray, isNull } from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import { getOrganizationProjectDatabases } from "@/db/services/project";
|
import { getOrganizationProjectDatabases } from "@/db/services/project";
|
||||||
import { getActiveMember, getOrganization } from "@/lib/auth/auth";
|
import { getActiveMember, getOrganization } from "@/lib/auth/auth";
|
||||||
@@ -49,38 +49,22 @@ export default async function RoutePage(
|
|||||||
redirect("/dashboard/projects");
|
redirect("/dashboard/projects");
|
||||||
}
|
}
|
||||||
|
|
||||||
const backups = await db.query.backup.findMany({
|
|
||||||
where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
|
|
||||||
with: {
|
|
||||||
restorations: true,
|
|
||||||
storages: {
|
|
||||||
with: {
|
|
||||||
storageChannel: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
logs: true,
|
|
||||||
},
|
|
||||||
orderBy: (b, { desc }) => [desc(b.createdAt)],
|
|
||||||
});
|
|
||||||
|
|
||||||
const restorations = await db.query.restoration.findMany({
|
|
||||||
where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
|
|
||||||
with: {
|
|
||||||
logs: true,
|
|
||||||
},
|
|
||||||
orderBy: (r, { desc }) => [desc(r.createdAt)],
|
|
||||||
});
|
|
||||||
|
|
||||||
//const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
|
|
||||||
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
|
|
||||||
|
|
||||||
const totalBackups = await db
|
const totalBackups = await db
|
||||||
.select({ count: drizzleDb.schemas.backup.id })
|
.select({ count: drizzleDb.schemas.backup.id })
|
||||||
.from(drizzleDb.schemas.backup)
|
.from(drizzleDb.schemas.backup)
|
||||||
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
|
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
|
||||||
.then((rows) => rows.length);
|
.then((rows) => rows.length);
|
||||||
|
|
||||||
const availableBackups = backups.filter((b) => !b.deletedAt).length;
|
const availableBackups = await db
|
||||||
|
.select({ count: drizzleDb.schemas.backup.id })
|
||||||
|
.from(drizzleDb.schemas.backup)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
|
||||||
|
isNull(drizzleDb.schemas.backup.deletedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then((rows) => rows.length);
|
||||||
|
|
||||||
const successfulBackups = await db
|
const successfulBackups = await db
|
||||||
.select({ count: drizzleDb.schemas.backup.id })
|
.select({ count: drizzleDb.schemas.backup.id })
|
||||||
@@ -93,6 +77,17 @@ export default async function RoutePage(
|
|||||||
)
|
)
|
||||||
.then((rows) => rows.length);
|
.then((rows) => rows.length);
|
||||||
|
|
||||||
|
const isAlreadyRestore = await db
|
||||||
|
.select({ count: drizzleDb.schemas.restoration.id })
|
||||||
|
.from(drizzleDb.schemas.restoration)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
|
||||||
|
eq(drizzleDb.schemas.restoration.status, "waiting"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then((rows) => rows.length > 0);
|
||||||
|
|
||||||
const [settings] = await db
|
const [settings] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(drizzleDb.schemas.setting)
|
.from(drizzleDb.schemas.setting)
|
||||||
@@ -109,8 +104,6 @@ export default async function RoutePage(
|
|||||||
const successRate =
|
const successRate =
|
||||||
totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
||||||
|
|
||||||
//const isMember = activeMember?.role === "member";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<LogsModalProvider>
|
<LogsModalProvider>
|
||||||
@@ -121,8 +114,6 @@ export default async function RoutePage(
|
|||||||
database={dbItem}
|
database={dbItem}
|
||||||
databaseHealthLogs={databaseHealthLogs}
|
databaseHealthLogs={databaseHealthLogs}
|
||||||
isAlreadyRestore={isAlreadyRestore}
|
isAlreadyRestore={isAlreadyRestore}
|
||||||
restorations={restorations}
|
|
||||||
backups={backups}
|
|
||||||
totalBackups={totalBackups}
|
totalBackups={totalBackups}
|
||||||
availableBackups={availableBackups}
|
availableBackups={availableBackups}
|
||||||
successRate={successRate}
|
successRate={successRate}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Page, PageContent, PageTitle } from "@/features/layout/components/page"
|
|||||||
import { ButtonDeleteProject } from "@/features/projects/components/project-delete-button";
|
import { ButtonDeleteProject } from "@/features/projects/components/project-delete-button";
|
||||||
import { CardsWithPagination } from "@/components/common/cards-with-pagination";
|
import { CardsWithPagination } from "@/components/common/cards-with-pagination";
|
||||||
import { ProjectDatabaseCard } from "@/features/projects/components/project-database-card";
|
import { ProjectDatabaseCard } from "@/features/projects/components/project-database-card";
|
||||||
|
import { ProjectDatabaseSelection } from "@/features/projects/components/project-database-selection";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -87,20 +88,27 @@ export default async function RoutePage(
|
|||||||
</div>
|
</div>
|
||||||
<PageContent className="flex flex-col w-full h-full">
|
<PageContent className="flex flex-col w-full h-full">
|
||||||
{proj.databases.length > 0 ? (
|
{proj.databases.length > 0 ? (
|
||||||
<CardsWithPagination
|
isMember ? (
|
||||||
data={[...proj.databases].sort(
|
<CardsWithPagination
|
||||||
(a, b) =>
|
data={[...proj.databases].sort(
|
||||||
new Date(b.createdAt).getTime() -
|
(a, b) =>
|
||||||
new Date(a.createdAt).getTime(),
|
new Date(b.createdAt).getTime() -
|
||||||
)}
|
new Date(a.createdAt).getTime(),
|
||||||
organizationSlug={organization.slug}
|
)}
|
||||||
// @ts-ignore
|
organizationSlug={organization.slug}
|
||||||
cardItem={ProjectDatabaseCard}
|
// @ts-ignore
|
||||||
cardsPerPage={20}
|
cardItem={ProjectDatabaseCard}
|
||||||
numberOfColumns={3}
|
cardsPerPage={20}
|
||||||
pageSizeOptions={[10, 20, 50]}
|
numberOfColumns={3}
|
||||||
extendedProps={proj}
|
pageSizeOptions={[10, 20, 50]}
|
||||||
/>
|
extendedProps={proj}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ProjectDatabaseSelection
|
||||||
|
projectId={proj.id}
|
||||||
|
databases={proj.databases}
|
||||||
|
/>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground py-20">
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground py-20">
|
||||||
<p className="text-lg font-medium">No databases found</p>
|
<p className="text-lg font-medium">No databases found</p>
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export default async function RoutePage(
|
|||||||
</PageDescription>
|
</PageDescription>
|
||||||
)}
|
)}
|
||||||
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
||||||
<AgentContentPage agent={agent} edgeKey={edgeKey}/>
|
<AgentContentPage agent={agent} edgeKey={edgeKey} canDeleteDatabases={!!agent.organizationId}/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
|||||||
const organizationWithMembers = await db.query.organization.findFirst({
|
const organizationWithMembers = await db.query.organization.findFirst({
|
||||||
where: eq(drizzleDb.schemas.organization.id, organization.id),
|
where: eq(drizzleDb.schemas.organization.id, organization.id),
|
||||||
with: {
|
with: {
|
||||||
projects: true,
|
projects: {
|
||||||
|
where: eq(drizzleDb.schemas.project.isArchived, false),
|
||||||
|
},
|
||||||
members: {
|
members: {
|
||||||
with: {
|
with: {
|
||||||
user: true,
|
user: true,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
if (!org) notFound();
|
if (!org) notFound();
|
||||||
|
|
||||||
const projects = await db.query.project.findMany({
|
const projects = await db.query.project.findMany({
|
||||||
where: eq(drizzleDb.schemas.project.organizationId, org.id),
|
where: and(eq(drizzleDb.schemas.project.organizationId, org.id), eq(drizzleDb.schemas.project.isArchived, false))
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectIds = projects.map(project => project.id);
|
const projectIds = projects.map(project => project.id);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {Agent} from "@/db/schema/08_agent";
|
|||||||
import {DatabaseWith} from "@/db/schema/07_database";
|
import {DatabaseWith} from "@/db/schema/07_database";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {db, db as dbClient} from "@/db";
|
import {db, db as dbClient} from "@/db";
|
||||||
import {and, eq, inArray} from "drizzle-orm";
|
import {and, eq, inArray, desc, sql} from "drizzle-orm";
|
||||||
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
|
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
|
||||||
import {withUpdatedAt} from "@/db/utils";
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
import {Setting} from "@/db/schema/01_setting";
|
import {Setting} from "@/db/schema/01_setting";
|
||||||
@@ -77,10 +77,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
||||||
if (databaseCreated) {
|
if (databaseCreated) {
|
||||||
|
|
||||||
|
|
||||||
await dbClient
|
await dbClient
|
||||||
.insert(drizzleDb.schemas.healthcheckLog)
|
.insert(drizzleDb.schemas.healthcheckLog)
|
||||||
.values({
|
.values({
|
||||||
@@ -123,7 +120,11 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
where: and(
|
where: and(
|
||||||
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
|
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
|
||||||
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"])
|
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"])
|
||||||
)
|
),
|
||||||
|
orderBy: [
|
||||||
|
sql`case when ${drizzleDb.schemas.backup.status} = 'waiting' then 0 else 1 end`,
|
||||||
|
desc(drizzleDb.schemas.backup.createdAt)
|
||||||
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
const restoration = await dbClient.query.restoration.findFirst({
|
const restoration = await dbClient.query.restoration.findFirst({
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import {NextResponse} from "next/server";
|
import {NextResponse} from "next/server";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import {and, eq} from "drizzle-orm";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
import {env} from "@/env.mjs";
|
import {env} from "@/env.mjs";
|
||||||
import {logger} from "@/lib/logger";
|
import {logger} from "@/lib/logger";
|
||||||
|
|
||||||
const log = logger.child({module: "api/tus/hooks"});
|
const log = logger.child({module: "api/tus/hooks"});
|
||||||
|
|
||||||
|
const FILE_PATH_RE = /^backups\/\d{4}-\d{2}-\d{2}\/[A-Za-z0-9._-]+$/;
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
@@ -24,17 +29,61 @@ export async function POST(request: Request) {
|
|||||||
event.Upload.Offset === event.Upload.Size
|
event.Upload.Offset === event.Upload.Size
|
||||||
) {
|
) {
|
||||||
const id = event.Upload.ID;
|
const id = event.Upload.ID;
|
||||||
const fileName = headers["X-File-Name"]?.[0];
|
|
||||||
const filePath = headers["X-File-Path"]?.[0];
|
const filePath = headers["X-File-Path"]?.[0];
|
||||||
|
|
||||||
if (!filePath) {
|
if (!filePath || !FILE_PATH_RE.test(filePath)) {
|
||||||
return NextResponse.json({error: "Missing X-File-Path"}, {status: 500});
|
log.warn({filePath}, "Rejected invalid X-File-Path in TUS hook");
|
||||||
|
return NextResponse.json({error: "Invalid file path"}, {status: 400});
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedId = headers["X-Generated-Id"]?.[0];
|
||||||
|
const backupStorageId = headers["X-Backup-Storage-Id"]?.[0];
|
||||||
|
|
||||||
|
|
||||||
|
console.log(`Backup Storage ID : ${backupStorageId}`);
|
||||||
|
console.log(`generatedId : ${generatedId}`);
|
||||||
|
|
||||||
|
if (backupStorageId) {
|
||||||
|
if (!generatedId) {
|
||||||
|
log.warn("Missing X-Generated-Id in TUS hook");
|
||||||
|
return NextResponse.json({error: "Forbidden"}, {status: 403});
|
||||||
|
}
|
||||||
|
|
||||||
|
const database = await db.query.database.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!database) {
|
||||||
|
log.warn({generatedId}, "No database for X-Generated-Id in TUS hook");
|
||||||
|
return NextResponse.json({error: "Forbidden"}, {status: 403});
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = await db.query.backupStorage.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.backupStorage.id, backupStorageId),
|
||||||
|
),
|
||||||
|
with: {backup: true},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!storage || storage.backup?.databaseId !== database.id) {
|
||||||
|
log.warn({backupStorageId, generatedId}, "No pending backup storage matches TUS hook");
|
||||||
|
return NextResponse.json({error: "Forbidden"}, {status: 403});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.warn("TUS hook without X-Backup-Storage-Id (legacy agent)");
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadDir = path.join(env.PRIVATE_PATH!, "/uploads/");
|
const uploadDir = path.join(env.PRIVATE_PATH!, "/uploads/");
|
||||||
|
|
||||||
const oldFilePath = path.join(uploadDir, "tmp", id);
|
const oldFilePath = path.join(uploadDir, "tmp", id);
|
||||||
const newFilePath = path.join(uploadDir, filePath);
|
|
||||||
|
const resolvedBase = path.resolve(uploadDir);
|
||||||
|
const newFilePath = path.resolve(resolvedBase, filePath);
|
||||||
|
const rel = path.relative(resolvedBase, newFilePath);
|
||||||
|
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||||
|
log.warn({filePath}, "Rejected path traversal in TUS hook");
|
||||||
|
return NextResponse.json({error: "Invalid file path"}, {status: 400});
|
||||||
|
}
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(newFilePath), {recursive: true});
|
fs.mkdirSync(path.dirname(newFilePath), {recursive: true});
|
||||||
|
|
||||||
@@ -74,7 +123,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
return NextResponse.json({});
|
return NextResponse.json({});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error({error: error},"TUS Hook error");
|
log.error({error: error}, "TUS Hook error");
|
||||||
return NextResponse.json({error: "Internal server error"}, {status: 500});
|
return NextResponse.json({error: "Internal server error"}, {status: 500});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -14,7 +14,12 @@ const queryClient = new QueryClient();
|
|||||||
export const Providers = (props: ProviderProps) => {
|
export const Providers = (props: ProviderProps) => {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="light"
|
||||||
|
enableSystem
|
||||||
|
storageKey="portabase_theme"
|
||||||
|
>
|
||||||
<ThemeMetaUpdater />
|
<ThemeMetaUpdater />
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ErrorLayout>
|
<ErrorLayout>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
# build:
|
build:
|
||||||
# context: .
|
context: .
|
||||||
# dockerfile: docker/dockerfile/Dockerfile
|
dockerfile: docker/dockerfile/Dockerfile
|
||||||
# target: prod
|
target: prod
|
||||||
image: portabase/portabase:1
|
# image: portabase/portabase:1
|
||||||
ports:
|
ports:
|
||||||
- "8887:80"
|
- "8887:80"
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -99,12 +99,58 @@ services:
|
|||||||
# clientEmail: "fake@test.iam.gserviceaccount.com",
|
# clientEmail: "fake@test.iam.gserviceaccount.com",
|
||||||
# privateKey: "unused-when-emulated",
|
# privateKey: "unused-when-emulated",
|
||||||
|
|
||||||
|
rustfs:
|
||||||
|
image: rustfs/rustfs:latest
|
||||||
|
container_name: rustfs
|
||||||
|
environment:
|
||||||
|
- RUSTFS_ROOT_USER=admin
|
||||||
|
- RUSTFS_ROOT_PASSWORD=root-password
|
||||||
|
- RUSTFS_ACCESS_KEY=access-key
|
||||||
|
- RUSTFS_SECRET_KEY=secret-key
|
||||||
|
- RUSTFS_VOLUMES=/data
|
||||||
|
- RUSTFS_ADDRESS=:9000
|
||||||
|
- RUSTFS_CONSOLE_ENABLE=true
|
||||||
|
- RUSTFS_CONSOLE_ADDRESS=:9001
|
||||||
|
volumes:
|
||||||
|
- rustfs-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD", "curl", "-sf", "http://localhost:9000/health" ]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
ports:
|
||||||
|
- "9001:9001"
|
||||||
|
- "9000:9000"
|
||||||
|
extra_hosts:
|
||||||
|
- "localhost:host-gateway"
|
||||||
|
networks:
|
||||||
|
- portabase
|
||||||
|
|
||||||
|
rustfs-init:
|
||||||
|
image: rustfs/rc:latest
|
||||||
|
container_name: rustfs-init
|
||||||
|
depends_on:
|
||||||
|
rustfs:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint: >
|
||||||
|
/bin/sh -c "
|
||||||
|
until (/usr/bin/rc alias set rustfs http://rustfs:9000 access-key secret-key) do echo '...waiting...' && sleep 1; done;
|
||||||
|
/usr/bin/rc mb rustfs/portabase;
|
||||||
|
echo 'RustFS initialization complete';
|
||||||
|
tail -f /dev/null
|
||||||
|
"
|
||||||
|
networks:
|
||||||
|
- portabase
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
azurite-data:
|
azurite-data:
|
||||||
gcs-data:
|
gcs-data:
|
||||||
gcs-storage:
|
gcs-storage:
|
||||||
|
rustfs-data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
portabase:
|
portabase:
|
||||||
|
|||||||
@@ -1,11 +1,33 @@
|
|||||||
FROM --platform=$BUILDPLATFORM node:22-bullseye AS base
|
FROM --platform=$BUILDPLATFORM node:22-slim AS runtime-base
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
curl \
|
curl \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
bash \
|
bash \
|
||||||
tzdata \
|
tzdata \
|
||||||
libc6 \
|
libc6 \
|
||||||
|
nginx \
|
||||||
|
gnupg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN mkdir -p /etc/apt/keyrings \
|
||||||
|
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||||||
|
| gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg \
|
||||||
|
&& echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" \
|
||||||
|
> /etc/apt/sources.list.d/pgdg.list
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
postgresql-18 \
|
||||||
|
postgresql-client-18 \
|
||||||
|
postgresql-contrib-18 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV PATH="/usr/lib/postgresql/18/bin:${PATH}"
|
||||||
|
|
||||||
|
|
||||||
|
FROM runtime-base AS build-base
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
g++ \
|
g++ \
|
||||||
make \
|
make \
|
||||||
@@ -13,25 +35,9 @@ RUN apt-get update && apt-get install -y \
|
|||||||
automake \
|
automake \
|
||||||
libtool \
|
libtool \
|
||||||
git \
|
git \
|
||||||
nginx \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|
||||||
RUN mkdir -p /etc/apt/keyrings \
|
|
||||||
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
|
||||||
| gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg \
|
|
||||||
&& echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" \
|
|
||||||
> /etc/apt/sources.list.d/pgdg.list
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
postgresql-18 \
|
|
||||||
postgresql-client-18 \
|
|
||||||
postgresql-contrib-18 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
ENV PATH="/usr/lib/postgresql/18/bin:${PATH}"
|
|
||||||
|
|
||||||
|
|
||||||
FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS tusd-base
|
FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS tusd-base
|
||||||
|
|
||||||
ARG TUSD_VERSION=2.8.0
|
ARG TUSD_VERSION=2.8.0
|
||||||
@@ -51,7 +57,7 @@ FROM scratch AS tusd-dist
|
|||||||
COPY --from=tusd-base /tusddist/tusd /tusd
|
COPY --from=tusd-base /tusddist/tusd /tusd
|
||||||
|
|
||||||
|
|
||||||
FROM base AS build-env
|
FROM build-base AS build-env
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
@@ -83,7 +89,7 @@ COPY . .
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
FROM base AS prod
|
FROM runtime-base AS prod
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
@@ -105,7 +111,6 @@ COPY --from=builder /app/drizzle.config.ts ./
|
|||||||
COPY --from=builder --chown=1001:1001 /app/.next/standalone ./
|
COPY --from=builder --chown=1001:1001 /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static
|
COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static
|
||||||
COPY --chown=1001:1001 src/db ./src/db
|
COPY --chown=1001:1001 src/db ./src/db
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
|
|
||||||
COPY --from=tusd-dist /tusd /usr/local/bin/tusd
|
COPY --from=tusd-dist /tusd /usr/local/bin/tusd
|
||||||
RUN chmod +x /usr/local/bin/tusd
|
RUN chmod +x /usr/local/bin/tusd
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ http {
|
|||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location = /api/tus/hooks {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:3000;
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
|
||||||
|
|||||||
+5
-38
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "portabase",
|
"name": "portabase",
|
||||||
"version": "1.20.6",
|
"version": "1.22.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 8887",
|
"dev": "next dev --turbopack -p 8887",
|
||||||
@@ -27,52 +27,38 @@
|
|||||||
"@onboardjs/react": "1.0.0-rc.5",
|
"@onboardjs/react": "1.0.0-rc.5",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-menubar": "^1.1.16",
|
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toast": "^1.2.15",
|
|
||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@react-email/components": "^0.0.41",
|
"@react-email/components": "^0.0.41",
|
||||||
"@t3-oss/env-nextjs": "^0.13.11",
|
"@t3-oss/env-nextjs": "^0.13.11",
|
||||||
"@tanstack/react-query": "^5.100.14",
|
"@tanstack/react-query": "^5.100.14",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/nodemailer": "^6.4.23",
|
"@types/nodemailer": "^6.4.23",
|
||||||
"@types/ws": "^8.18.1",
|
|
||||||
"@zenstackhq/runtime": "2.14.2",
|
|
||||||
"argon2": "^0.43.1",
|
|
||||||
"bcrypt": "^6.0.0",
|
|
||||||
"better-auth": "1.6.11",
|
"better-auth": "1.6.11",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.3.0",
|
"date-fns": "^4.3.0",
|
||||||
"dockerode": "^4.0.12",
|
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"drizzle-zod": "0.8.3",
|
"drizzle-zod": "0.8.3",
|
||||||
"embla-carousel-react": "^8.6.0",
|
|
||||||
"googleapis": "^170.1.0",
|
"googleapis": "^170.1.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
@@ -82,33 +68,22 @@
|
|||||||
"next-safe-action": "^7.10.8",
|
"next-safe-action": "^7.10.8",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"node-forge": "^1.4.0",
|
|
||||||
"nodemailer": "8.0.5",
|
"nodemailer": "8.0.5",
|
||||||
"npm-check-updates": "^18.3.1",
|
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.21.0",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-pretty": "^13.1.3",
|
|
||||||
"prettier": "^3.8.3",
|
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-day-picker": "9.7.0",
|
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-dropzone": "^14.4.1",
|
"react-dropzone": "^14.4.1",
|
||||||
"react-email": "^4.3.2",
|
"react-email": "^4.3.2",
|
||||||
"react-hook-form": "^7.76.1",
|
"react-hook-form": "^7.76.1",
|
||||||
"react-qr-code": "^2.0.21",
|
"react-qr-code": "^2.0.21",
|
||||||
"react-resizable-panels": "^3.0.6",
|
|
||||||
"react-twc": "^1.5.1",
|
"react-twc": "^1.5.1",
|
||||||
"react-use-measure": "^2.1.7",
|
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
|
"server-only": "^0.0.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"socket.io": "^4.8.3",
|
|
||||||
"socket.io-client": "^4.8.3",
|
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"swiper": "^12.1.4",
|
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"uuid": "^11.1.1",
|
"uuid": "^11.1.1",
|
||||||
"vaul": "^1.1.2",
|
|
||||||
"ws": "^8.21.0",
|
|
||||||
"zod": "4.3.6"
|
"zod": "4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -119,29 +94,21 @@
|
|||||||
"@release-it/conventional-changelog": "^10.0.6",
|
"@release-it/conventional-changelog": "^10.0.6",
|
||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
|
||||||
"@types/node": "^22.19.19",
|
"@types/node": "^22.19.19",
|
||||||
"@types/node-forge": "^1.3.14",
|
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19.2.15",
|
"@types/react": "^19.2.15",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@zenstackhq/openapi": "^2.22.1",
|
|
||||||
"@zenstackhq/tanstack-query": "^2.22.2",
|
|
||||||
"baseline-browser-mapping": "^2.10.32",
|
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"esbuild": "^0.27.7",
|
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "^16.2.6",
|
"eslint-config-next": "^16.2.6",
|
||||||
"eslint-plugin-tailwindcss": "^3.18.3",
|
|
||||||
"framer-motion": "^12.40.0",
|
"framer-motion": "^12.40.0",
|
||||||
"node-pty": "^1.1.0",
|
"pino-pretty": "^13.1.3",
|
||||||
"postcss": "8.5.10",
|
"postcss": "8.5.10",
|
||||||
|
"postcss-load-config": "^6.0.1",
|
||||||
"release-it": "^19.2.4",
|
"release-it": "^19.2.4",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"tsx": "^4.22.3",
|
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3"
|
||||||
"zenstack": "2.14.2"
|
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26"
|
"packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+61
-2520
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -9,6 +9,8 @@ import {
|
|||||||
SortingState,
|
SortingState,
|
||||||
ColumnFiltersState,
|
ColumnFiltersState,
|
||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
|
PaginationState,
|
||||||
|
OnChangeFn,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table";
|
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table";
|
||||||
@@ -42,6 +44,13 @@ interface DataTableProps<TData, TValue> {
|
|||||||
};
|
};
|
||||||
highlightRow?: (row: TData) => boolean;
|
highlightRow?: (row: TData) => boolean;
|
||||||
selectedActions?: (rows: TData[]) => ReactNode;
|
selectedActions?: (rows: TData[]) => ReactNode;
|
||||||
|
manualPagination?: boolean;
|
||||||
|
rowCount?: number;
|
||||||
|
paginationState?: { pageIndex: number; pageSize: number };
|
||||||
|
onPaginationChange?: OnChangeFn<PaginationState>;
|
||||||
|
sorting?: SortingState;
|
||||||
|
onSortingChange?: OnChangeFn<SortingState>;
|
||||||
|
isFetching?: boolean;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,10 +65,19 @@ export function DataTable<TData, TValue>({
|
|||||||
emptyButton,
|
emptyButton,
|
||||||
highlightRow,
|
highlightRow,
|
||||||
selectedActions,
|
selectedActions,
|
||||||
|
manualPagination = false,
|
||||||
|
rowCount,
|
||||||
|
paginationState,
|
||||||
|
onPaginationChange,
|
||||||
|
sorting: controlledSorting,
|
||||||
|
onSortingChange: controlledOnSortingChange,
|
||||||
|
isFetching = false,
|
||||||
|
|
||||||
|
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [internalSorting, setInternalSorting] = useState<SortingState>([]);
|
||||||
|
const sorting = controlledSorting ?? internalSorting;
|
||||||
|
const setSorting = controlledOnSortingChange ?? setInternalSorting;
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
const [rowSelection, setRowSelection] = useState({});
|
const [rowSelection, setRowSelection] = useState({});
|
||||||
|
|
||||||
@@ -96,7 +114,7 @@ export function DataTable<TData, TValue>({
|
|||||||
data,
|
data,
|
||||||
columns: finalColumns,
|
columns: finalColumns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
getPaginationRowModel: manualPagination ? undefined : getPaginationRowModel(),
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
@@ -106,10 +124,15 @@ export function DataTable<TData, TValue>({
|
|||||||
autoResetPageIndex: false,
|
autoResetPageIndex: false,
|
||||||
autoResetExpanded: false,
|
autoResetExpanded: false,
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
|
manualPagination,
|
||||||
|
manualSorting: manualPagination,
|
||||||
|
rowCount: manualPagination ? rowCount : undefined,
|
||||||
|
onPaginationChange: manualPagination ? onPaginationChange : undefined,
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
columnFilters,
|
columnFilters,
|
||||||
rowSelection,
|
rowSelection,
|
||||||
|
...(manualPagination && paginationState ? { pagination: paginationState } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -194,18 +217,22 @@ export function DataTable<TData, TValue>({
|
|||||||
selected.
|
selected.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{enablePagination && table.getFilteredRowModel().rows.length >= 1 && (
|
{enablePagination && (manualPagination ? (rowCount ?? 0) > 0 : table.getFilteredRowModel().rows.length >= 1) && (
|
||||||
<TablePagination
|
<TablePagination
|
||||||
table={table}
|
table={table}
|
||||||
maxVisiblePages={paginationOptions?.pageVisible}
|
maxVisiblePages={paginationOptions?.pageVisible}
|
||||||
pageSizeOptions={(() => {
|
pageSizeOptions={
|
||||||
const rowCount = table.getFilteredRowModel().rows.length;
|
manualPagination
|
||||||
const allSizes = paginationOptions.pageSize.sort((a, b) => a - b);
|
? paginationOptions.pageSize
|
||||||
const validSizes = allSizes.filter((size) => size <= rowCount);
|
: (() => {
|
||||||
const nextSize = allSizes.find((size) => size > rowCount);
|
const rowCountLocal = table.getFilteredRowModel().rows.length;
|
||||||
if (nextSize) validSizes.push(nextSize);
|
const allSizes = paginationOptions.pageSize.sort((a, b) => a - b);
|
||||||
return validSizes;
|
const validSizes = allSizes.filter((size) => size <= rowCountLocal);
|
||||||
})()}
|
const nextSize = allSizes.find((size) => size > rowCountLocal);
|
||||||
|
if (nextSize) validSizes.push(nextSize);
|
||||||
|
return validSizes;
|
||||||
|
})()
|
||||||
|
}
|
||||||
className={paginationOptions.className}
|
className={paginationOptions.className}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import {Card} from "@/components/ui/card";
|
import {Card} from "@/components/ui/card";
|
||||||
@@ -8,28 +6,54 @@ import {ConnectionIndicator} from "@/components/common/connection-indicator";
|
|||||||
import {formatDateLastContact} from "@/utils/date-formatting";
|
import {formatDateLastContact} from "@/utils/date-formatting";
|
||||||
import {Database} from "@/db/schema/07_database";
|
import {Database} from "@/db/schema/07_database";
|
||||||
import {ChevronRight, Activity, Fingerprint, Copy, Check} from "lucide-react";
|
import {ChevronRight, Activity, Fingerprint, Copy, Check} from "lucide-react";
|
||||||
|
import {cn} from "@/lib/utils";
|
||||||
|
|
||||||
export type DatabaseCardProps = {
|
export type DatabaseCardProps = {
|
||||||
data: Database;
|
data: Database;
|
||||||
withDetails?: boolean;
|
withDetails?: boolean;
|
||||||
|
selectable?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
onToggleSelect?: (databaseId: string) => void;
|
||||||
|
deleteButton?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DatabaseCard = (props: DatabaseCardProps) => {
|
export const DatabaseCard = (props: DatabaseCardProps) => {
|
||||||
const {data: database, withDetails = true} = props;
|
const {data: database, withDetails = true, selectable = false, selected = false, onToggleSelect, deleteButton} = props;
|
||||||
const [isCopied, setIsCopied] = useState(false);
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
|
|
||||||
const handleCopy = (e: React.MouseEvent) => {
|
const handleCopy = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigator.clipboard.writeText(database.agentDatabaseId);
|
navigator.clipboard.writeText(database.agentDatabaseId ?? "");
|
||||||
setIsCopied(true);
|
setIsCopied(true);
|
||||||
setTimeout(() => setIsCopied(false), 2000);
|
setTimeout(() => setIsCopied(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="relative h-full flex flex-col p-4 transition-all border-border/50 bg-card hover:bg-accent/50 hover:border-primary/50 group-hover:shadow-lg overflow-hidden gap-0">
|
className={cn(
|
||||||
<div className="flex items-start justify-between mb-2">
|
"relative h-full flex flex-col p-4 transition-all border-border/50 bg-card hover:bg-accent/50 hover:border-primary/50 group-hover:shadow-lg overflow-hidden gap-0",
|
||||||
|
selected && "ring-1 ring-primary border-primary",
|
||||||
|
)}>
|
||||||
|
{selectable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={selected}
|
||||||
|
aria-label={selected ? "Deselect database" : "Select database"}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleSelect?.(database.id);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"absolute top-3 left-3 z-20 w-5 h-5 rounded-md border-2 flex items-center justify-center transition-colors",
|
||||||
|
selected ? "bg-primary border-primary text-primary-foreground" : "border-muted-foreground/50 bg-background/70",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selected && <Check className="w-3 h-3" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className={cn("flex items-start justify-between mb-2", selectable && "pl-7")}>
|
||||||
<div
|
<div
|
||||||
className="relative w-12 h-12 p-2 bg-background rounded-xl border border-border/50 shadow-sm flex items-center justify-center group-hover:border-primary/30 transition-all duration-300 group-hover:scale-105">
|
className="relative w-12 h-12 p-2 bg-background rounded-xl border border-border/50 shadow-sm flex items-center justify-center group-hover:border-primary/30 transition-all duration-300 group-hover:scale-105">
|
||||||
<Image
|
<Image
|
||||||
@@ -82,7 +106,12 @@ export const DatabaseCard = (props: DatabaseCardProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{withDetails && (
|
{deleteButton ? (
|
||||||
|
<div className="mt-4 flex items-center justify-end pt-3 border-t border-border/50"
|
||||||
|
onClick={(e) => e.preventDefault()}>
|
||||||
|
{deleteButton}
|
||||||
|
</div>
|
||||||
|
) : withDetails ? (
|
||||||
<div className="mt-4 flex items-center justify-between pt-3 border-t border-border/50">
|
<div className="mt-4 flex items-center justify-between pt-3 border-t border-border/50">
|
||||||
<span
|
<span
|
||||||
className="text-[10px] font-black uppercase tracking-widest text-primary opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-x-[-10px] group-hover:translate-x-0">Open</span>
|
className="text-[10px] font-black uppercase tracking-widest text-primary opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-x-[-10px] group-hover:translate-x-0">Open</span>
|
||||||
@@ -93,7 +122,7 @@ export const DatabaseCard = (props: DatabaseCardProps) => {
|
|||||||
<ChevronRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform"/>
|
<ChevronRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "public"."dbms_status" ADD VALUE 'docker-volume';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "databases" ALTER COLUMN "agent_database_id" DROP NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "databases" ALTER COLUMN "agent_id" DROP NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -484,6 +484,20 @@
|
|||||||
"when": 1782575192480,
|
"when": 1782575192480,
|
||||||
"tag": "0068_busy_zeigeist",
|
"tag": "0068_busy_zeigeist",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 69,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782937885439,
|
||||||
|
"tag": "0069_worried_alex_wilder",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 70,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783264084846,
|
||||||
|
"tag": "0070_illegal_joseph",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,7 @@ import {JobLog, jobLog} from "@/db/schema/17_job-log";
|
|||||||
|
|
||||||
export const database = pgTable("databases", {
|
export const database = pgTable("databases", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
agentDatabaseId: uuid("agent_database_id").notNull().defaultRandom(),
|
agentDatabaseId: uuid("agent_database_id").defaultRandom(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
dbms: dbmsEnum("dbms").notNull(),
|
dbms: dbmsEnum("dbms").notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
@@ -22,7 +22,6 @@ export const database = pgTable("databases", {
|
|||||||
backupToRestore: text("backup_to_restore"),
|
backupToRestore: text("backup_to_restore"),
|
||||||
healthErrorCount: integer("health_error_count"),
|
healthErrorCount: integer("health_error_count"),
|
||||||
agentId: uuid("agent_id")
|
agentId: uuid("agent_id")
|
||||||
.notNull()
|
|
||||||
.references(() => agent.id, {onDelete: "cascade"}),
|
.references(() => agent.id, {onDelete: "cascade"}),
|
||||||
lastContact: timestamp("last_contact"),
|
lastContact: timestamp("last_contact"),
|
||||||
projectId: uuid("project_id")
|
projectId: uuid("project_id")
|
||||||
@@ -143,10 +142,12 @@ export type BackupWith = Backup & {
|
|||||||
restorations?: Restoration[] | null;
|
restorations?: Restoration[] | null;
|
||||||
storages?: BackupStorage[] | null;
|
storages?: BackupStorage[] | null;
|
||||||
logs?: JobLog[] | null;
|
logs?: JobLog[] | null;
|
||||||
|
hasLogs?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RestorationWith = Restoration & {
|
export type RestorationWith = Restoration & {
|
||||||
logs?: JobLog[] | null;
|
logs?: JobLog[] | null;
|
||||||
|
hasLogs?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {pgEnum} from "drizzle-orm/pg-core";
|
|||||||
import {createSelectSchema} from "drizzle-zod";
|
import {createSelectSchema} from "drizzle-zod";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
|
|
||||||
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "postgresql-cluster", "mysql", "mariadb", "mongodb", "sqlite", "redis", "valkey", "firebird", "mssql"]);
|
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "postgresql-cluster", "mysql", "mariadb", "mongodb", "sqlite", "redis", "valkey", "firebird", "mssql", "docker-volume"]);
|
||||||
export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
|
export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
|
||||||
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
|
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ export async function getOrganizationAvailableDatabases(
|
|||||||
projectId?: string,
|
projectId?: string,
|
||||||
) {
|
) {
|
||||||
const availableDatabases = (await db.query.database.findMany({
|
const availableDatabases = (await db.query.database.findMany({
|
||||||
where: (db, { eq, or, isNull }) =>
|
where: (db, { eq, or, and, isNull }) =>
|
||||||
projectId
|
and(
|
||||||
? or(isNull(db.projectId), eq(db.projectId, projectId))
|
projectId
|
||||||
: isNull(db.projectId),
|
? or(isNull(db.projectId), eq(db.projectId, projectId))
|
||||||
|
: isNull(db.projectId),
|
||||||
|
isNull(db.deletedAt),
|
||||||
|
),
|
||||||
with: {
|
with: {
|
||||||
agent: {
|
agent: {
|
||||||
with: {
|
with: {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import { and, eq, gte, isNotNull, lt } from "drizzle-orm";
|
import { and, eq, gte, isNotNull, isNull, lt } from "drizzle-orm";
|
||||||
import { dispatchNotification } from "@/features/notifications/utils/notifications.dispatch";
|
import { dispatchNotification } from "@/features/notifications/utils/notifications.dispatch";
|
||||||
import { EventPayload } from "@/features/notifications/types";
|
import { EventPayload } from "@/features/notifications/types";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
@@ -113,7 +113,10 @@ export async function checkAgentsHealthError() {
|
|||||||
|
|
||||||
export async function checkDatabasesHealthError() {
|
export async function checkDatabasesHealthError() {
|
||||||
const databases = await db.query.database.findMany({
|
const databases = await db.query.database.findMany({
|
||||||
where: isNotNull(drizzleDb.schemas.database.lastContact),
|
where: and(
|
||||||
|
isNotNull(drizzleDb.schemas.database.lastContact),
|
||||||
|
isNull(drizzleDb.schemas.database.deletedAt),
|
||||||
|
),
|
||||||
with: {
|
with: {
|
||||||
agent: true,
|
agent: true,
|
||||||
alertPolicies: true,
|
alertPolicies: true,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { getOrganization } from "@/lib/auth/auth";
|
import { getOrganization } from "@/lib/auth/auth";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import { project } from "@/db/schema/06_project";
|
import { project } from "@/db/schema/06_project";
|
||||||
|
|
||||||
@@ -10,7 +10,9 @@ export async function getOrganizationProject(organizationId: string) {
|
|||||||
return db.query.project.findFirst({
|
return db.query.project.findFirst({
|
||||||
where: eq(project.organizationId, organizationId),
|
where: eq(project.organizationId, organizationId),
|
||||||
with: {
|
with: {
|
||||||
databases: true,
|
databases: {
|
||||||
|
where: isNull(drizzleDb.schemas.database.deletedAt),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -39,7 +41,9 @@ export const getOrganizationProjectDatabases = async ({
|
|||||||
eq(drizzleDb.schemas.project.id, projectId),
|
eq(drizzleDb.schemas.project.id, projectId),
|
||||||
),
|
),
|
||||||
with: {
|
with: {
|
||||||
databases: true,
|
databases: {
|
||||||
|
where: isNull(drizzleDb.schemas.database.deletedAt),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export const env = createEnv({
|
|||||||
process.env.NODE_ENV === "production" ? "0 * * * *" : "* * * * *",
|
process.env.NODE_ENV === "production" ? "0 * * * *" : "* * * * *",
|
||||||
),
|
),
|
||||||
|
|
||||||
|
STALE_BACKUP_THRESHOLD_HOURS: z.coerce.number().default(6),
|
||||||
|
|
||||||
AUTH_OIDC_ID: z.string().optional().default("oidc"),
|
AUTH_OIDC_ID: z.string().optional().default("oidc"),
|
||||||
AUTH_OIDC_TITLE: z.string().optional(),
|
AUTH_OIDC_TITLE: z.string().optional(),
|
||||||
@@ -147,6 +148,7 @@ export const env = createEnv({
|
|||||||
|
|
||||||
RETENTION_CRON: process.env.RETENTION_CRON,
|
RETENTION_CRON: process.env.RETENTION_CRON,
|
||||||
CLEANING_HEALTHCHECK_LOGS_CRON: process.env.CLEANING_HEALTHCHECK_LOGS_CRON,
|
CLEANING_HEALTHCHECK_LOGS_CRON: process.env.CLEANING_HEALTHCHECK_LOGS_CRON,
|
||||||
|
STALE_BACKUP_THRESHOLD_HOURS: process.env.STALE_BACKUP_THRESHOLD_HOURS,
|
||||||
|
|
||||||
AUTH_OIDC_ID: process.env.AUTH_OIDC_ID,
|
AUTH_OIDC_ID: process.env.AUTH_OIDC_ID,
|
||||||
AUTH_OIDC_TITLE: process.env.AUTH_OIDC_TITLE,
|
AUTH_OIDC_TITLE: process.env.AUTH_OIDC_TITLE,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {userAction} from "@/lib/safe-actions/actions";
|
|||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
import {ServerActionResult} from "@/types/action-type";
|
import {ServerActionResult} from "@/types/action-type";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {and, eq, inArray} from "drizzle-orm";
|
import {and, eq, inArray, isNull} from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {AgentWith} from "@/db/schema/08_agent";
|
import {AgentWith} from "@/db/schema/08_agent";
|
||||||
import {withUpdatedAt} from "@/db/utils";
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
@@ -25,7 +25,9 @@ export const updateAgentOrganizationsAction = userAction
|
|||||||
where: eq(drizzleDb.schemas.agent.id, agentId),
|
where: eq(drizzleDb.schemas.agent.id, agentId),
|
||||||
with: {
|
with: {
|
||||||
organizations: true,
|
organizations: true,
|
||||||
databases: true
|
databases: {
|
||||||
|
where: isNull(drizzleDb.schemas.database.deletedAt),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}) as AgentWith;
|
}) as AgentWith;
|
||||||
|
|
||||||
@@ -70,7 +72,7 @@ export const updateAgentOrganizationsAction = userAction
|
|||||||
|
|
||||||
if (projectIds.length > 0) {
|
if (projectIds.length > 0) {
|
||||||
const databases = await db.query.database.findMany({
|
const databases = await db.query.database.findMany({
|
||||||
where: (db, { inArray }) => inArray(db.projectId, projectIds),
|
where: (db, { inArray, and, isNull }) => and(inArray(db.projectId, projectIds), isNull(db.deletedAt)),
|
||||||
columns: { id: true }
|
columns: { id: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import {action, ActionError, userAction} from "@/lib/safe-actions/actions";
|
import {action, ActionError, userAction} from "@/lib/safe-actions/actions";
|
||||||
import {AgentSchema} from "@/features/agents/schemas/agents.schema";
|
import {AgentSchema} from "@/features/agents/schemas/agents.schema";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
import {eq, and, ne, count, desc} from "drizzle-orm";
|
import {eq, and, ne, count, desc, isNull} from "drizzle-orm";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {slugify} from "@/utils/slugify";
|
import {slugify} from "@/utils/slugify";
|
||||||
@@ -87,7 +87,9 @@ export const getAgentAction = userAction.schema(z.string()).action(async ({parse
|
|||||||
const agent = await db.query.agent.findFirst({
|
const agent = await db.query.agent.findFirst({
|
||||||
where: eq(drizzleDb.schemas.agent.id, parsedInput),
|
where: eq(drizzleDb.schemas.agent.id, parsedInput),
|
||||||
with: {
|
with: {
|
||||||
databases: true
|
databases: {
|
||||||
|
where: isNull(drizzleDb.schemas.database.deletedAt),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
|
|||||||
type AgentContentPageProps = {
|
type AgentContentPageProps = {
|
||||||
edgeKey: string;
|
edgeKey: string;
|
||||||
agent: AgentWithDatabases
|
agent: AgentWithDatabases
|
||||||
|
canDeleteDatabases?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPageProps) => {
|
export const AgentContentPage = ({edgeKey, agent: initialAgent, canDeleteDatabases = false}: AgentContentPageProps) => {
|
||||||
|
|
||||||
const {data} = useQuery({
|
const {data} = useQuery({
|
||||||
queryKey: ["agent-data", initialAgent.id],
|
queryKey: ["agent-data", initialAgent.id],
|
||||||
@@ -121,6 +122,7 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
|
|||||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
)}
|
)}
|
||||||
cardItem={AgentDatabaseCard}
|
cardItem={AgentDatabaseCard}
|
||||||
|
canDeleteDatabases={canDeleteDatabases}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {Database} from "@/db/schema/07_database";
|
import { Database } from "@/db/schema/07_database";
|
||||||
import {DatabaseCard} from "@/components/common/database-card";
|
import { DatabaseCard } from "@/components/common/database-card";
|
||||||
|
import { DatabaseDeleteButton } from "@/features/agents/components/database-delete-button";
|
||||||
|
|
||||||
export type agentDatabaseCardProps = {
|
export type AgentDatabaseCardProps = {
|
||||||
data: Database;
|
data: Database;
|
||||||
|
canDeleteDatabases?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AgentDatabaseCard = (props: agentDatabaseCardProps) => {
|
export const AgentDatabaseCard = (props: AgentDatabaseCardProps) => {
|
||||||
const {data: database} = props;
|
const { data: database, canDeleteDatabases = false } = props;
|
||||||
|
|
||||||
return <DatabaseCard withDetails={false} data={database}/>;
|
return (
|
||||||
};
|
<DatabaseCard
|
||||||
|
withDetails={false}
|
||||||
|
data={database}
|
||||||
|
deleteButton={
|
||||||
|
canDeleteDatabases && database.agentId ? (
|
||||||
|
<DatabaseDeleteButton
|
||||||
|
databaseId={database.id}
|
||||||
|
agentId={database.agentId}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {Trash2} from "lucide-react";
|
||||||
|
import {ButtonWithConfirm} from "@/components/common/button-with-confirm";
|
||||||
|
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
import {deleteDatabaseAction} from "@/features/database/actions/database-delete.action";
|
||||||
|
|
||||||
|
export type DatabaseDeleteButtonProps = {
|
||||||
|
databaseId: string;
|
||||||
|
agentId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DatabaseDeleteButton = (props: DatabaseDeleteButtonProps) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => deleteDatabaseAction({databaseId: props.databaseId}),
|
||||||
|
onSuccess: async (result: any) => {
|
||||||
|
if (result.data?.success) {
|
||||||
|
toast.success(result.data.actionSuccess.message);
|
||||||
|
await queryClient.invalidateQueries({queryKey: ["agent-data", props.agentId]});
|
||||||
|
} else {
|
||||||
|
toast.error(result.data?.actionError?.message || "Unknown error occurred.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to delete database.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonWithConfirm
|
||||||
|
title="Delete database"
|
||||||
|
description="Are you sure you want to delete this database? It will be unlinked from this agent."
|
||||||
|
button={{
|
||||||
|
main: {
|
||||||
|
text: "",
|
||||||
|
variant: "outline",
|
||||||
|
size: "sm",
|
||||||
|
icon: <Trash2 color="red"/>,
|
||||||
|
},
|
||||||
|
confirm: {
|
||||||
|
className: "w-full",
|
||||||
|
text: "Delete",
|
||||||
|
icon: <Trash2/>,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => {
|
||||||
|
mutation.mutate();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancel: {
|
||||||
|
className: "w-full",
|
||||||
|
text: "Cancel",
|
||||||
|
variant: "outline",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
isPending={mutation.isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -105,7 +105,7 @@ export const StorageS3Form = ({ form }: StorageS3FormProps) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Use SSL</FormLabel>
|
<FormLabel>Use SSL</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
<Switch checked={field.value ?? true} onCheckedChange={field.onChange} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
import {userAction} from "@/lib/safe-actions/actions";
|
import {userAction} from "@/lib/safe-actions/actions";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {eq} from "drizzle-orm";
|
import {and, count, eq, inArray, isNull} from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {BackupWith, RestorationWith} from "@/db/schema/07_database";
|
|
||||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
||||||
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
||||||
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
|
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
|
||||||
@@ -19,7 +18,10 @@ export const getDatabaseDataAction = userAction
|
|||||||
const {databaseId} = parsedInput;
|
const {databaseId} = parsedInput;
|
||||||
|
|
||||||
const database = await db.query.database.findFirst({
|
const database = await db.query.database.findFirst({
|
||||||
where: eq(drizzleDb.schemas.database.id, databaseId),
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.database.id, databaseId),
|
||||||
|
isNull(drizzleDb.schemas.database.deletedAt),
|
||||||
|
),
|
||||||
with: {
|
with: {
|
||||||
project: true,
|
project: true,
|
||||||
retentionPolicy: true,
|
retentionPolicy: true,
|
||||||
@@ -28,33 +30,50 @@ export const getDatabaseDataAction = userAction
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const backups = await db.query.backup.findMany({
|
const [totalRow] = await db
|
||||||
where: eq(drizzleDb.schemas.backup.databaseId, databaseId),
|
.select({count: count()})
|
||||||
with: {
|
.from(drizzleDb.schemas.backup)
|
||||||
restorations: true,
|
.where(eq(drizzleDb.schemas.backup.databaseId, databaseId));
|
||||||
storages: {
|
const totalBackups = totalRow?.count ?? 0;
|
||||||
with: {
|
|
||||||
storageChannel: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
logs: true
|
|
||||||
},
|
|
||||||
orderBy: (b, {desc}) => [desc(b.createdAt)],
|
|
||||||
}) as BackupWith[];
|
|
||||||
|
|
||||||
const restorations = await db.query.restoration.findMany({
|
const [availableRow] = await db
|
||||||
where: eq(drizzleDb.schemas.restoration.databaseId, databaseId),
|
.select({count: count()})
|
||||||
with: {
|
.from(drizzleDb.schemas.backup)
|
||||||
logs: true
|
.where(and(
|
||||||
},
|
eq(drizzleDb.schemas.backup.databaseId, databaseId),
|
||||||
orderBy: (r, {desc}) => [desc(r.createdAt)],
|
isNull(drizzleDb.schemas.backup.deletedAt),
|
||||||
}) as RestorationWith[];
|
));
|
||||||
|
const availableBackups = availableRow?.count ?? 0;
|
||||||
|
|
||||||
|
const [successRow] = await db
|
||||||
|
.select({count: count()})
|
||||||
|
.from(drizzleDb.schemas.backup)
|
||||||
|
.where(and(
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, databaseId),
|
||||||
|
eq(drizzleDb.schemas.backup.status, "success"),
|
||||||
|
));
|
||||||
|
const successfulBackups = successRow?.count ?? 0;
|
||||||
|
|
||||||
const totalBackups = backups.length;
|
|
||||||
const availableBackups = backups.filter(b => !b.deletedAt).length;
|
|
||||||
const successfulBackups = backups.filter(b => b.status === "success").length;
|
|
||||||
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
||||||
|
|
||||||
|
const [activeBackupRow] = await db
|
||||||
|
.select({count: count()})
|
||||||
|
.from(drizzleDb.schemas.backup)
|
||||||
|
.where(and(
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, databaseId),
|
||||||
|
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"]),
|
||||||
|
));
|
||||||
|
const isAlreadyBackup = (activeBackupRow?.count ?? 0) > 0;
|
||||||
|
|
||||||
|
const [activeRestoreRow] = await db
|
||||||
|
.select({count: count()})
|
||||||
|
.from(drizzleDb.schemas.restoration)
|
||||||
|
.where(and(
|
||||||
|
eq(drizzleDb.schemas.restoration.databaseId, databaseId),
|
||||||
|
eq(drizzleDb.schemas.restoration.status, "waiting"),
|
||||||
|
));
|
||||||
|
const isAlreadyRestore = (activeRestoreRow?.count ?? 0) > 0;
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
let activeOrganizationChannels = [];
|
let activeOrganizationChannels = [];
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -68,11 +87,8 @@ export const getDatabaseDataAction = userAction
|
|||||||
activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled);
|
activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
database,
|
database,
|
||||||
backups,
|
|
||||||
restorations,
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
activeOrganizationChannels,
|
activeOrganizationChannels,
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -82,6 +98,8 @@ export const getDatabaseDataAction = userAction
|
|||||||
availableBackups,
|
availableBackups,
|
||||||
successRate
|
successRate
|
||||||
},
|
},
|
||||||
health: database ? await getHealthLast12hLogs({ id: database.id }) : []
|
isAlreadyRestore,
|
||||||
|
isAlreadyBackup,
|
||||||
|
health: database ? await getHealthLast12hLogs({id: database.id}) : []
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"use server";
|
||||||
|
import { userAction } from "@/lib/safe-actions/actions";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { and, count, eq, inArray, isNotNull, isNull } from "drizzle-orm";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import { BackupWith, RestorationWith } from "@/db/schema/07_database";
|
||||||
|
import {
|
||||||
|
FetchBackupsSchema,
|
||||||
|
FetchRestorationsSchema,
|
||||||
|
} from "@/features/database/actions/backup-list.schema";
|
||||||
|
|
||||||
|
export const fetchBackupsAction = userAction
|
||||||
|
.schema(FetchBackupsSchema)
|
||||||
|
.action(async ({ parsedInput }) => {
|
||||||
|
const { databaseId, page, pageSize, filter } = parsedInput;
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const deletedCondition =
|
||||||
|
filter === "available"
|
||||||
|
? isNull(drizzleDb.schemas.backup.deletedAt)
|
||||||
|
: filter === "deleted"
|
||||||
|
? isNotNull(drizzleDb.schemas.backup.deletedAt)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const where = and(
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, databaseId),
|
||||||
|
deletedCondition,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [totalResult] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(drizzleDb.schemas.backup)
|
||||||
|
.where(where);
|
||||||
|
|
||||||
|
const data = (await db.query.backup.findMany({
|
||||||
|
where,
|
||||||
|
with: {
|
||||||
|
restorations: true,
|
||||||
|
storages: {
|
||||||
|
with: {
|
||||||
|
storageChannel: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: (b, { desc }) => [desc(b.createdAt)],
|
||||||
|
limit: pageSize,
|
||||||
|
offset,
|
||||||
|
})) as BackupWith[];
|
||||||
|
|
||||||
|
const backupIds = data.map((b) => b.id);
|
||||||
|
const backupsWithLogs = backupIds.length
|
||||||
|
? await db
|
||||||
|
.select({ backupId: drizzleDb.schemas.jobLog.backupId })
|
||||||
|
.from(drizzleDb.schemas.jobLog)
|
||||||
|
.where(inArray(drizzleDb.schemas.jobLog.backupId, backupIds))
|
||||||
|
.groupBy(drizzleDb.schemas.jobLog.backupId)
|
||||||
|
: [];
|
||||||
|
const loggedBackupIds = new Set(backupsWithLogs.map((l) => l.backupId));
|
||||||
|
for (const b of data) {
|
||||||
|
b.hasLogs = loggedBackupIds.has(b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = totalResult?.count ?? 0;
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
meta: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchRestorationsAction = userAction
|
||||||
|
.schema(FetchRestorationsSchema)
|
||||||
|
.action(async ({ parsedInput }) => {
|
||||||
|
const { databaseId, page, pageSize } = parsedInput;
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where = eq(drizzleDb.schemas.restoration.databaseId, databaseId);
|
||||||
|
|
||||||
|
const [totalResult] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(drizzleDb.schemas.restoration)
|
||||||
|
.where(where);
|
||||||
|
|
||||||
|
const data = (await db.query.restoration.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: (r, { desc }) => [desc(r.createdAt)],
|
||||||
|
limit: pageSize,
|
||||||
|
offset,
|
||||||
|
})) as RestorationWith[];
|
||||||
|
|
||||||
|
const restorationIds = data.map((r) => r.id);
|
||||||
|
const restorationsWithLogs = restorationIds.length
|
||||||
|
? await db
|
||||||
|
.select({ restorationId: drizzleDb.schemas.jobLog.restorationId })
|
||||||
|
.from(drizzleDb.schemas.jobLog)
|
||||||
|
.where(inArray(drizzleDb.schemas.jobLog.restorationId, restorationIds))
|
||||||
|
.groupBy(drizzleDb.schemas.jobLog.restorationId)
|
||||||
|
: [];
|
||||||
|
const loggedRestorationIds = new Set(
|
||||||
|
restorationsWithLogs.map((l) => l.restorationId),
|
||||||
|
);
|
||||||
|
for (const r of data) {
|
||||||
|
r.hasLogs = loggedRestorationIds.has(r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = totalResult?.count ?? 0;
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
meta: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const FetchBackupsSchema = z.object({
|
||||||
|
databaseId: z.string(),
|
||||||
|
page: z.number().int().min(1).default(1),
|
||||||
|
pageSize: z.number().int().min(1).max(100).default(20),
|
||||||
|
filter: z.enum(["available", "deleted"]).optional(),
|
||||||
|
sorting: z.any().optional(),
|
||||||
|
});
|
||||||
|
export type FetchBackupsSchema = z.infer<typeof FetchBackupsSchema>;
|
||||||
|
|
||||||
|
export const FetchRestorationsSchema = z.object({
|
||||||
|
databaseId: z.string(),
|
||||||
|
page: z.number().int().min(1).default(1),
|
||||||
|
pageSize: z.number().int().min(1).max(100).default(20),
|
||||||
|
sorting: z.any().optional(),
|
||||||
|
});
|
||||||
|
export type FetchRestorationsSchema = z.infer<typeof FetchRestorationsSchema>;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import {z} from "zod";
|
||||||
|
import {and, inArray, isNull} from "drizzle-orm";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {ServerActionResult} from "@/types/action-type";
|
||||||
|
import {userAction} from "@/lib/safe-actions/actions";
|
||||||
|
import {assertDatabasesInOrgProject} from "@/features/database/actions/bulk-restore.action";
|
||||||
|
|
||||||
|
const bulkSchema = z.object({
|
||||||
|
projectId: z.uuid(),
|
||||||
|
databaseIds: z.array(z.uuid()).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bulkBackupAction = userAction
|
||||||
|
.schema(bulkSchema)
|
||||||
|
.action(async ({parsedInput}): Promise<ServerActionResult<{queued: number; skipped: {databaseId: string; reason: string}[]}>> => {
|
||||||
|
try {
|
||||||
|
const {projectId, databaseIds} = parsedInput;
|
||||||
|
await assertDatabasesInOrgProject(projectId, databaseIds);
|
||||||
|
|
||||||
|
const activeBackups = await db.query.backup.findMany({
|
||||||
|
where: and(
|
||||||
|
inArray(drizzleDb.schemas.backup.databaseId, databaseIds),
|
||||||
|
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"]),
|
||||||
|
isNull(drizzleDb.schemas.backup.deletedAt),
|
||||||
|
),
|
||||||
|
columns: {databaseId: true},
|
||||||
|
});
|
||||||
|
const busy = new Set(activeBackups.map((b) => b.databaseId));
|
||||||
|
const toQueue = databaseIds.filter((id) => !busy.has(id));
|
||||||
|
const skipped = [...busy].map((databaseId) => ({databaseId, reason: "backup already in progress"}));
|
||||||
|
|
||||||
|
if (toQueue.length > 0) {
|
||||||
|
await db
|
||||||
|
.insert(drizzleDb.schemas.backup)
|
||||||
|
.values(toQueue.map((databaseId) => ({databaseId, status: "waiting" as const})));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
value: {queued: toQueue.length, skipped},
|
||||||
|
actionSuccess: {
|
||||||
|
message: `Queued ${toQueue.length} backup(s)${skipped.length ? `, ${skipped.length} skipped` : ""}.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "Failed to queue backups.",
|
||||||
|
status: 500,
|
||||||
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import {z} from "zod";
|
||||||
|
import {and, desc, eq, inArray, isNull} from "drizzle-orm";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {ServerActionResult} from "@/types/action-type";
|
||||||
|
import {userAction, ActionError} from "@/lib/safe-actions/actions";
|
||||||
|
import {getOrganization} from "@/lib/auth/auth";
|
||||||
|
|
||||||
|
export async function assertDatabasesInOrgProject(projectId: string, databaseIds: string[]) {
|
||||||
|
const organization = await getOrganization({});
|
||||||
|
if (!organization) throw new ActionError("No active organization.");
|
||||||
|
|
||||||
|
const project = await db.query.project.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.project.id, projectId),
|
||||||
|
eq(drizzleDb.schemas.project.organizationId, organization.id),
|
||||||
|
),
|
||||||
|
with: {databases: {where: isNull(drizzleDb.schemas.database.deletedAt)}},
|
||||||
|
});
|
||||||
|
if (!project) throw new ActionError("Project not found.");
|
||||||
|
|
||||||
|
const allowed = new Set(project.databases.map((d) => d.id));
|
||||||
|
for (const id of databaseIds) {
|
||||||
|
if (!allowed.has(id)) throw new ActionError("Database not in project.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RestorePreviewRow = {
|
||||||
|
databaseId: string;
|
||||||
|
name: string;
|
||||||
|
backupId?: string;
|
||||||
|
backupStorageId?: string;
|
||||||
|
backupDate?: string;
|
||||||
|
restorable: boolean;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function resolveLatestRestorable(projectId: string, databaseIds: string[]): Promise<RestorePreviewRow[]> {
|
||||||
|
const project = await db.query.project.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.project.id, projectId),
|
||||||
|
with: {databases: {where: isNull(drizzleDb.schemas.database.deletedAt)}},
|
||||||
|
});
|
||||||
|
const nameById = new Map((project?.databases ?? []).map((d) => [d.id, d.name] as const));
|
||||||
|
|
||||||
|
const rows: RestorePreviewRow[] = [];
|
||||||
|
for (const databaseId of databaseIds) {
|
||||||
|
const activeRestore = await db.query.restoration.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.restoration.databaseId, databaseId),
|
||||||
|
inArray(drizzleDb.schemas.restoration.status, ["waiting", "ongoing"]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (activeRestore) {
|
||||||
|
rows.push({
|
||||||
|
databaseId,
|
||||||
|
name: nameById.get(databaseId) ?? databaseId,
|
||||||
|
restorable: false,
|
||||||
|
reason: "restore already in progress",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backups = await db.query.backup.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, databaseId),
|
||||||
|
isNull(drizzleDb.schemas.backup.deletedAt),
|
||||||
|
),
|
||||||
|
orderBy: desc(drizzleDb.schemas.backup.createdAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
let picked: RestorePreviewRow | undefined;
|
||||||
|
for (const backup of backups) {
|
||||||
|
const storage = await db.query.backupStorage.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.backupStorage.backupId, backup.id),
|
||||||
|
eq(drizzleDb.schemas.backupStorage.status, "success"),
|
||||||
|
isNull(drizzleDb.schemas.backupStorage.deletedAt),
|
||||||
|
),
|
||||||
|
orderBy: desc(drizzleDb.schemas.backupStorage.createdAt),
|
||||||
|
});
|
||||||
|
if (storage) {
|
||||||
|
picked = {
|
||||||
|
databaseId,
|
||||||
|
name: nameById.get(databaseId) ?? databaseId,
|
||||||
|
backupId: backup.id,
|
||||||
|
backupStorageId: storage.id,
|
||||||
|
backupDate: backup.createdAt?.toISOString(),
|
||||||
|
restorable: true,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push(picked ?? {databaseId, name: nameById.get(databaseId) ?? databaseId, restorable: false, reason: "no successful backup"});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bulkSchema = z.object({
|
||||||
|
projectId: z.uuid(),
|
||||||
|
databaseIds: z.array(z.uuid()).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bulkRestorePreviewAction = userAction
|
||||||
|
.schema(bulkSchema)
|
||||||
|
.action(async ({parsedInput}): Promise<ServerActionResult<RestorePreviewRow[]>> => {
|
||||||
|
try {
|
||||||
|
const {projectId, databaseIds} = parsedInput;
|
||||||
|
await assertDatabasesInOrgProject(projectId, databaseIds);
|
||||||
|
const rows = await resolveLatestRestorable(projectId, databaseIds);
|
||||||
|
return {success: true, value: rows};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "Failed to build restore preview.",
|
||||||
|
status: 500,
|
||||||
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bulkRestoreLatestAction = userAction
|
||||||
|
.schema(bulkSchema)
|
||||||
|
.action(async ({parsedInput}): Promise<ServerActionResult<{queued: number; skipped: {databaseId: string; reason: string}[]}>> => {
|
||||||
|
try {
|
||||||
|
const {projectId, databaseIds} = parsedInput;
|
||||||
|
await assertDatabasesInOrgProject(projectId, databaseIds);
|
||||||
|
const rows = await resolveLatestRestorable(projectId, databaseIds);
|
||||||
|
|
||||||
|
const restorable = rows.filter((r) => r.restorable);
|
||||||
|
const skipped = rows
|
||||||
|
.filter((r) => !r.restorable)
|
||||||
|
.map((r) => ({databaseId: r.databaseId, reason: r.reason ?? "no successful backup"}));
|
||||||
|
|
||||||
|
if (restorable.length > 0) {
|
||||||
|
await db.insert(drizzleDb.schemas.restoration).values(
|
||||||
|
restorable.map((r) => ({
|
||||||
|
databaseId: r.databaseId,
|
||||||
|
backupId: r.backupId!,
|
||||||
|
backupStorageId: r.backupStorageId!,
|
||||||
|
status: "waiting" as const,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
value: {queued: restorable.length, skipped},
|
||||||
|
actionSuccess: {
|
||||||
|
message: `Queued ${restorable.length} restore(s)${skipped.length ? `, ${skipped.length} skipped` : ""}.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "Failed to queue restores.",
|
||||||
|
status: 500,
|
||||||
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import {z} from "zod";
|
||||||
|
import {eq} from "drizzle-orm";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {Database} from "@/db/schema/07_database";
|
||||||
|
import {ServerActionResult} from "@/types/action-type";
|
||||||
|
import {userAction} from "@/lib/safe-actions/actions";
|
||||||
|
import {zString} from "@/lib/zod";
|
||||||
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
|
import {currentUser} from "@/lib/auth/current-user";
|
||||||
|
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||||
|
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
|
||||||
|
|
||||||
|
type DeleteDatabaseInput = {
|
||||||
|
databaseId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
class DatabaseNotFoundError extends Error {
|
||||||
|
constructor(databaseId: string) {
|
||||||
|
super(`Database not found or update failed: ${databaseId}`);
|
||||||
|
this.name = "DatabaseNotFoundError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UnauthorizedError extends Error {
|
||||||
|
constructor(databaseId: string) {
|
||||||
|
super(`Not authorized to delete this database: ${databaseId}`);
|
||||||
|
this.name = "UnauthorizedError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertCanDeleteDatabase(databaseId: string): Promise<void> {
|
||||||
|
const database = await db.query.database.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.database.id, databaseId),
|
||||||
|
with: {
|
||||||
|
agent: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!database || !database.agent) {
|
||||||
|
throw new DatabaseNotFoundError(databaseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await currentUser();
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedError(databaseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = database.agent;
|
||||||
|
const isAdmin = user.role === "superadmin" || user.role === "admin";
|
||||||
|
|
||||||
|
let authorized: boolean;
|
||||||
|
if (agent.organizationId === null) {
|
||||||
|
authorized = isAdmin;
|
||||||
|
} else {
|
||||||
|
const organization = await getOrganization({});
|
||||||
|
const activeMember = await getActiveMember();
|
||||||
|
const canManage = activeMember
|
||||||
|
? computeOrganizationPermissions(activeMember).canManageAgents
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const hasAccess =
|
||||||
|
!!organization && agent.organizationId === organization.id;
|
||||||
|
authorized = canManage && hasAccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authorized) {
|
||||||
|
throw new UnauthorizedError(databaseId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDatabaseService(input: DeleteDatabaseInput): Promise<Database> {
|
||||||
|
const {databaseId} = input;
|
||||||
|
|
||||||
|
await assertCanDeleteDatabase(databaseId);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(drizzleDb.schemas.retentionPolicy)
|
||||||
|
.where(eq(drizzleDb.schemas.retentionPolicy.databaseId, databaseId))
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(drizzleDb.schemas.alertPolicy)
|
||||||
|
.where(eq(drizzleDb.schemas.alertPolicy.databaseId, databaseId))
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(drizzleDb.schemas.storagePolicy)
|
||||||
|
.where(eq(drizzleDb.schemas.storagePolicy.databaseId, databaseId))
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
const [updatedDatabase] = await db
|
||||||
|
.update(drizzleDb.schemas.database)
|
||||||
|
.set(
|
||||||
|
withUpdatedAt({
|
||||||
|
agentId: null,
|
||||||
|
agentDatabaseId: null,
|
||||||
|
projectId: null,
|
||||||
|
backupPolicy: null,
|
||||||
|
deletedAt: new Date(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.where(eq(drizzleDb.schemas.database.id, databaseId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updatedDatabase) {
|
||||||
|
throw new DatabaseNotFoundError(databaseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedDatabase;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteDatabaseAction = userAction
|
||||||
|
.schema(
|
||||||
|
z.object({
|
||||||
|
databaseId: zString(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.action(async ({parsedInput}): Promise<ServerActionResult<Database>> => {
|
||||||
|
try {
|
||||||
|
const deletedDatabase = await deleteDatabaseService(parsedInput);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
value: deletedDatabase,
|
||||||
|
actionSuccess: {
|
||||||
|
message: "Database has been successfully deleted.",
|
||||||
|
messageParams: {
|
||||||
|
databaseId: parsedInput.databaseId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UnauthorizedError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "Not authorized to delete this database.",
|
||||||
|
status: 403,
|
||||||
|
messageParams: {
|
||||||
|
databaseId: parsedInput.databaseId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof DatabaseNotFoundError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "Database not found or update failed",
|
||||||
|
status: 404,
|
||||||
|
messageParams: {
|
||||||
|
databaseId: parsedInput.databaseId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "Failed to delete database.",
|
||||||
|
status: 500,
|
||||||
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
messageParams: {
|
||||||
|
databaseId: parsedInput.databaseId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ import {db} from "@/db";
|
|||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {Backup} from "@/db/schema/07_database";
|
import {Backup} from "@/db/schema/07_database";
|
||||||
import {v4 as uuidv4} from "uuid";
|
import {v4 as uuidv4} from "uuid";
|
||||||
import {eq} from "drizzle-orm";
|
import {and, eq, isNull} from "drizzle-orm";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
import {storeBackupFiles} from "@/features/storages/utils/storages.helpers";
|
import {storeBackupFiles} from "@/features/storages/utils/storages.helpers";
|
||||||
import {getFileExtension} from "@/utils/common";
|
import {getFileExtension} from "@/utils/common";
|
||||||
@@ -19,7 +19,10 @@ export const uploadBackupAction = userAction
|
|||||||
const databaseId = formData.get("databaseId") as string;
|
const databaseId = formData.get("databaseId") as string;
|
||||||
|
|
||||||
const database = await db.query.database.findFirst({
|
const database = await db.query.database.findFirst({
|
||||||
where: eq(drizzleDb.schemas.database.id, databaseId),
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.database.id, databaseId),
|
||||||
|
isNull(drizzleDb.schemas.database.deletedAt),
|
||||||
|
),
|
||||||
with: {
|
with: {
|
||||||
project: true,
|
project: true,
|
||||||
alertPolicies: true,
|
alertPolicies: true,
|
||||||
|
|||||||
@@ -101,6 +101,12 @@ export const BackupActionsForm = ({
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["database-data", backup.databaseId],
|
queryKey: ["database-data", backup.databaseId],
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["backups", backup.databaseId],
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["restorations", backup.databaseId],
|
||||||
|
});
|
||||||
router.refresh();
|
router.refresh();
|
||||||
if (action === "download") {
|
if (action === "download") {
|
||||||
const url = inner.value;
|
const url = inner.value;
|
||||||
@@ -121,6 +127,9 @@ export const BackupActionsForm = ({
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["database-data", backup.databaseId],
|
queryKey: ["database-data", backup.databaseId],
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["backups", backup.databaseId],
|
||||||
|
});
|
||||||
router.refresh();
|
router.refresh();
|
||||||
closeModal();
|
closeModal();
|
||||||
} else {
|
} else {
|
||||||
@@ -144,6 +153,9 @@ export const BackupActionsForm = ({
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["database-data", backup.databaseId],
|
queryKey: ["database-data", backup.databaseId],
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["backups", backup.databaseId],
|
||||||
|
});
|
||||||
router.refresh();
|
router.refresh();
|
||||||
closeModal();
|
closeModal();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export const BackupButton = (props: BackupButtonProps) => {
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["database-data", props.databaseId],
|
queryKey: ["database-data", props.databaseId],
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["backups", props.databaseId],
|
||||||
|
});
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast.error(backup?.serverError || "Failed to create backup.");
|
toast.error(backup?.serverError || "Failed to create backup.");
|
||||||
|
|||||||
@@ -112,8 +112,7 @@ export function backupColumns(
|
|||||||
accessorKey: "logs",
|
accessorKey: "logs",
|
||||||
header: "Logs",
|
header: "Logs",
|
||||||
cell: ({row}) => {
|
cell: ({row}) => {
|
||||||
const logs = row.original.logs ?? [];
|
return <LogsModalTrigger backupId={row.original.id} hasLogs={row.original.hasLogs}/>;
|
||||||
return <LogsModalTrigger logs={logs}/>;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,23 +13,22 @@ import {toast} from "sonner";
|
|||||||
import {MemberWithUser} from "@/db/schema/03_organization";
|
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||||
import {deleteBackupAction} from "@/features/database/actions/backup-actions.action";
|
import {deleteBackupAction} from "@/features/database/actions/backup-actions.action";
|
||||||
import {ButtonWithConfirm} from "@/components/common/button-with-confirm";
|
import {ButtonWithConfirm} from "@/components/common/button-with-confirm";
|
||||||
|
import {useServerDataTable} from "@/hooks/use-server-data-table";
|
||||||
|
import {fetchBackupsAction} from "@/features/database/actions/backup-list.action";
|
||||||
|
import type {FetchBackupsSchema} from "@/features/database/actions/backup-list.schema";
|
||||||
|
|
||||||
type DatabaseBackupListProps = {
|
type DatabaseBackupListProps = {
|
||||||
isAlreadyRestore: boolean;
|
isAlreadyRestore: boolean;
|
||||||
settings: Setting;
|
settings: Setting;
|
||||||
database: DatabaseWith;
|
database: DatabaseWith;
|
||||||
backups: BackupWith[];
|
activeMember: MemberWithUser;
|
||||||
activeMember: MemberWithUser
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
|
export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{label: "Deleted", value: "deleted"},
|
{label: "Deleted", value: "deleted"},
|
||||||
{label: "Available", value: "available"},
|
{label: "Available", value: "available"},
|
||||||
]
|
];
|
||||||
|
|
||||||
const [selectedFilters, setSelectedFilters] = useState<FilterItem[]>([items[1]]);
|
const [selectedFilters, setSelectedFilters] = useState<FilterItem[]>([items[1]]);
|
||||||
const [isActionsOpen, setIsActionsOpen] = useState(false);
|
const [isActionsOpen, setIsActionsOpen] = useState(false);
|
||||||
@@ -39,20 +38,19 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
|
|||||||
return backupColumns(props.isAlreadyRestore, props.settings, props.database, props.activeMember);
|
return backupColumns(props.isAlreadyRestore, props.settings, props.database, props.activeMember);
|
||||||
}, [props.isAlreadyRestore, props.activeMember.id, props.activeMember.role]);
|
}, [props.isAlreadyRestore, props.activeMember.id, props.activeMember.role]);
|
||||||
|
|
||||||
const filteredBackups = useMemo(() => {
|
const filter = useMemo<"available" | "deleted" | undefined>(() => {
|
||||||
if (!props.backups) return [];
|
const values = selectedFilters.map((f) => f.value);
|
||||||
|
if (values.length === 1) return values[0] as "available" | "deleted";
|
||||||
return props.backups.filter(backup => {
|
return undefined;
|
||||||
if (selectedFilters.length > 0) {
|
}, [selectedFilters]);
|
||||||
const selectedValues = selectedFilters.map(f => f.value);
|
|
||||||
const status = backup.deletedAt != null ? "deleted" : "available";
|
|
||||||
if (!selectedValues.includes(status)) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}, [props.backups, selectedFilters]);
|
|
||||||
|
|
||||||
|
const {data, tableProps, isFetching, isLoading} = useServerDataTable<BackupWith, FetchBackupsSchema>({
|
||||||
|
fetchAction: fetchBackupsAction,
|
||||||
|
queryKey: ["backups", props.database.id],
|
||||||
|
extraParams: {databaseId: props.database.id, filter},
|
||||||
|
initialPageSize: 20,
|
||||||
|
refetchInterval: 4000,
|
||||||
|
});
|
||||||
|
|
||||||
const handleSelectFilter = (item: FilterItem) => {
|
const handleSelectFilter = (item: FilterItem) => {
|
||||||
setSelectedFilters(prev =>
|
setSelectedFilters(prev =>
|
||||||
@@ -64,29 +62,26 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
|
|||||||
|
|
||||||
const clearFilters = () => setSelectedFilters([]);
|
const clearFilters = () => setSelectedFilters([]);
|
||||||
|
|
||||||
|
|
||||||
const mutationDeleteBackups = useMutation({
|
const mutationDeleteBackups = useMutation({
|
||||||
mutationFn: async (backups: Backup[]) => {
|
mutationFn: async (backups: Backup[]) => {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
backups.map(async (backup) => {
|
backups.map(async (backup) => {
|
||||||
if (backup.deletedAt == null || backup.status == "ongoing") {
|
if (backup.deletedAt == null || backup.status == "ongoing") {
|
||||||
|
|
||||||
const backupDeleted = await deleteBackupAction({
|
const backupDeleted = await deleteBackupAction({
|
||||||
databaseId: backup.databaseId,
|
databaseId: backup.databaseId,
|
||||||
backupId: backup.id,
|
backupId: backup.id,
|
||||||
})
|
});
|
||||||
return {
|
return {
|
||||||
success: backupDeleted?.data?.success,
|
success: backupDeleted?.data?.success,
|
||||||
message: backupDeleted?.data?.success
|
message: backupDeleted?.data?.success
|
||||||
? backupDeleted?.data?.actionSuccess?.message
|
? backupDeleted?.data?.actionSuccess?.message
|
||||||
// @ts-ignore
|
: "Failed to delete backup.",
|
||||||
: restoration?.data?.actionError.message,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Already deleted this backup (ref: ${backup.id}).`,
|
message: `Already deleted this backup (ref: ${backup.id}).`,
|
||||||
}
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
results.forEach((result) => {
|
results.forEach((result) => {
|
||||||
@@ -96,7 +91,7 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
|
|||||||
toast.error(result.message);
|
toast.error(result.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({queryKey: ["database-data", props.database.id]});
|
queryClient.invalidateQueries({queryKey: ["backups", props.database.id]});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,8 +101,10 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
|
|||||||
<DataTable
|
<DataTable
|
||||||
enableSelect={!isMember}
|
enableSelect={!isMember}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={filteredBackups}
|
data={data}
|
||||||
enablePagination
|
enablePagination
|
||||||
|
isFetching={isFetching || isLoading}
|
||||||
|
{...tableProps}
|
||||||
selectedActions={(rows) => (
|
selectedActions={(rows) => (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-start md:justify-between gap-3 md:gap-0 items-center w-full ml-0">
|
<div className="flex justify-start md:justify-between gap-3 md:gap-0 items-center w-full ml-0">
|
||||||
@@ -156,9 +153,8 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import {DatabaseBackupActionsModal} from "@/features/database/components/backup-actions-modal";
|
import {DatabaseBackupActionsModal} from "@/features/database/components/backup-actions-modal";
|
||||||
import {DatabaseTabs} from "@/features/database/components/database-tabs";
|
import {DatabaseTabs} from "@/features/database/components/database-tabs";
|
||||||
import {Setting} from "@/db/schema/01_setting";
|
import {Setting} from "@/db/schema/01_setting";
|
||||||
import {BackupWith, DatabaseWith, RestorationWith} from "@/db/schema/07_database";
|
import {DatabaseWith} from "@/db/schema/07_database";
|
||||||
import {MemberWithUser} from "@/db/schema/03_organization";
|
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||||
import {useBackupModal} from "@/features/database/components/backup-modal-context";
|
import {useBackupModal} from "@/features/database/components/backup-modal-context";
|
||||||
import {DatabaseKpi} from "@/features/database/components/database-kpi";
|
import {DatabaseKpi} from "@/features/database/components/database-kpi";
|
||||||
@@ -27,8 +27,6 @@ import {LogsModal} from "@/features/logs/components/logs-modal";
|
|||||||
|
|
||||||
export type DatabaseContentProps = {
|
export type DatabaseContentProps = {
|
||||||
settings: Setting;
|
settings: Setting;
|
||||||
backups: BackupWith[];
|
|
||||||
restorations: RestorationWith[];
|
|
||||||
isAlreadyRestore: boolean;
|
isAlreadyRestore: boolean;
|
||||||
database: DatabaseWith;
|
database: DatabaseWith;
|
||||||
activeMember: MemberWithUser;
|
activeMember: MemberWithUser;
|
||||||
@@ -53,14 +51,11 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
|
|||||||
return result?.data;
|
return result?.data;
|
||||||
},
|
},
|
||||||
initialData: {
|
initialData: {
|
||||||
// TODO : to be patched
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
database: {
|
database: {
|
||||||
...props.database,
|
...props.database,
|
||||||
project: props.database.project ?? null,
|
project: props.database.project ?? null,
|
||||||
},
|
},
|
||||||
backups: props.backups,
|
|
||||||
restorations: props.restorations,
|
|
||||||
activeOrganizationChannels: props.activeOrganizationChannels,
|
activeOrganizationChannels: props.activeOrganizationChannels,
|
||||||
activeOrganizationStorageChannels:
|
activeOrganizationStorageChannels:
|
||||||
props.activeOrganizationStorageChannels,
|
props.activeOrganizationStorageChannels,
|
||||||
@@ -69,16 +64,16 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
|
|||||||
availableBackups: props.availableBackups,
|
availableBackups: props.availableBackups,
|
||||||
successRate: props.successRate,
|
successRate: props.successRate,
|
||||||
},
|
},
|
||||||
|
isAlreadyRestore: props.isAlreadyRestore,
|
||||||
|
isAlreadyBackup: false,
|
||||||
health: props.databaseHealthLogs
|
health: props.databaseHealthLogs
|
||||||
},
|
},
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
gcTime: 0,
|
gcTime: 0,
|
||||||
refetchInterval: 1000,
|
refetchInterval: 4000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const database = data?.database ?? props.database;
|
const database = data?.database ?? props.database;
|
||||||
const backups = data?.backups ?? props.backups;
|
|
||||||
const restorations = data?.restorations ?? props.restorations;
|
|
||||||
const activeOrganizationChannels =
|
const activeOrganizationChannels =
|
||||||
data?.activeOrganizationChannels ?? props.activeOrganizationChannels;
|
data?.activeOrganizationChannels ?? props.activeOrganizationChannels;
|
||||||
const activeOrganizationStorageChannels =
|
const activeOrganizationStorageChannels =
|
||||||
@@ -90,10 +85,8 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
|
|||||||
successRate: props.successRate,
|
successRate: props.successRate,
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
|
const isAlreadyRestore = data?.isAlreadyRestore ?? props.isAlreadyRestore;
|
||||||
const isAlreadyBackup = backups.some(
|
const isAlreadyBackup = data?.isAlreadyBackup ?? false;
|
||||||
(b) => b.status === "waiting" || b.status === "ongoing",
|
|
||||||
);
|
|
||||||
|
|
||||||
const isMember = props.activeMember.role === "member";
|
const isMember = props.activeMember.role === "member";
|
||||||
|
|
||||||
@@ -128,7 +121,9 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
|
|||||||
channels={activeOrganizationStorageChannels}
|
channels={activeOrganizationStorageChannels}
|
||||||
organizationId={props.organizationId}
|
organizationId={props.organizationId}
|
||||||
/>
|
/>
|
||||||
<ImportModal database={database}/>
|
{database.dbms != "docker-volume" && (
|
||||||
|
<ImportModal database={database}/>
|
||||||
|
)}
|
||||||
<HealthModal database={database} healthLogs={data?.health ?? []}/>
|
<HealthModal database={database} healthLogs={data?.health ?? []}/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,8 +159,6 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
|
|||||||
settings={props.settings}
|
settings={props.settings}
|
||||||
database={database}
|
database={database}
|
||||||
isAlreadyRestore={isAlreadyRestore}
|
isAlreadyRestore={isAlreadyRestore}
|
||||||
backups={backups}
|
|
||||||
restorations={restorations}
|
|
||||||
/>
|
/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,18 +4,19 @@ import {restoreColumns} from "@/features/database/components/restore-columns";
|
|||||||
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu";
|
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu";
|
||||||
import {ButtonWithLoading} from "@/components/common/button-with-loading";
|
import {ButtonWithLoading} from "@/components/common/button-with-loading";
|
||||||
import {MoreHorizontal, Trash2} from "lucide-react";
|
import {MoreHorizontal, Trash2} from "lucide-react";
|
||||||
import {Restoration} from "@/db/schema/07_database";
|
import {Restoration, RestorationWith} from "@/db/schema/07_database";
|
||||||
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
||||||
import {deleteRestoreAction} from "@/features/database/actions/restore.action";
|
import {deleteRestoreAction} from "@/features/database/actions/restore.action";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {MemberWithUser} from "@/db/schema/03_organization";
|
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||||
import {useMemo, useState} from "react";
|
import {useMemo, useState} from "react";
|
||||||
import {ButtonWithConfirm} from "@/components/common/button-with-confirm";
|
import {ButtonWithConfirm} from "@/components/common/button-with-confirm";
|
||||||
|
import {useServerDataTable} from "@/hooks/use-server-data-table";
|
||||||
|
import {fetchRestorationsAction} from "@/features/database/actions/backup-list.action";
|
||||||
|
import type {FetchRestorationsSchema} from "@/features/database/actions/backup-list.schema";
|
||||||
|
|
||||||
type DatabaseRestoreListProps = {
|
type DatabaseRestoreListProps = {
|
||||||
isAlreadyRestore: boolean;
|
isAlreadyRestore: boolean;
|
||||||
restorations: Restoration[];
|
|
||||||
activeMember: MemberWithUser;
|
activeMember: MemberWithUser;
|
||||||
databaseId: string;
|
databaseId: string;
|
||||||
}
|
}
|
||||||
@@ -28,6 +29,14 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
|
|||||||
return restoreColumns(props.isAlreadyRestore, props.activeMember);
|
return restoreColumns(props.isAlreadyRestore, props.activeMember);
|
||||||
}, [props.isAlreadyRestore, props.activeMember.id, props.activeMember.role]);
|
}, [props.isAlreadyRestore, props.activeMember.id, props.activeMember.role]);
|
||||||
|
|
||||||
|
const {data, tableProps, isFetching, isLoading} = useServerDataTable<RestorationWith, FetchRestorationsSchema>({
|
||||||
|
fetchAction: fetchRestorationsAction,
|
||||||
|
queryKey: ["restorations", props.databaseId],
|
||||||
|
extraParams: {databaseId: props.databaseId},
|
||||||
|
initialPageSize: 20,
|
||||||
|
refetchInterval: 4000,
|
||||||
|
});
|
||||||
|
|
||||||
const mutationDeleteRestorations = useMutation({
|
const mutationDeleteRestorations = useMutation({
|
||||||
mutationFn: async (restorations: Restoration[]) => {
|
mutationFn: async (restorations: Restoration[]) => {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
@@ -42,8 +51,6 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
: restorationDeleted?.data?.actionError.message,
|
: restorationDeleted?.data?.actionError.message,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
results.forEach((result) => {
|
results.forEach((result) => {
|
||||||
@@ -53,18 +60,19 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
|
|||||||
toast.error(result.message);
|
toast.error(result.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({queryKey: ["database-data", props.databaseId]});
|
queryClient.invalidateQueries({queryKey: ["restorations", props.databaseId]});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const isMember = props.activeMember.role === "member";
|
const isMember = props.activeMember.role === "member";
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
enableSelect={!isMember}
|
enableSelect={!isMember}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={props.restorations}
|
data={data}
|
||||||
enablePagination
|
enablePagination
|
||||||
|
isFetching={isFetching || isLoading}
|
||||||
|
{...tableProps}
|
||||||
selectedActions={(rows) => (
|
selectedActions={(rows) => (
|
||||||
<>
|
<>
|
||||||
{!isMember && (
|
{!isMember && (
|
||||||
@@ -84,7 +92,7 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
|
|||||||
>Actions</ButtonWithLoading>
|
>Actions</ButtonWithLoading>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
<ButtonWithConfirm
|
<ButtonWithConfirm
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
mutationDeleteRestorations.mutate(rows)
|
mutationDeleteRestorations.mutate(rows)
|
||||||
setIsActionsOpen(false);
|
setIsActionsOpen(false);
|
||||||
@@ -110,5 +118,5 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {useRouter, useSearchParams} from "next/navigation";
|
import {useSearchParams} from "next/navigation";
|
||||||
import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database";
|
import {DatabaseWith} from "@/db/schema/07_database";
|
||||||
import {Setting} from "@/db/schema/01_setting";
|
import {Setting} from "@/db/schema/01_setting";
|
||||||
import {DatabaseBackupList} from "@/features/database/components/database-backup-list";
|
import {DatabaseBackupList} from "@/features/database/components/database-backup-list";
|
||||||
import {DatabaseRestoreList} from "@/features/database/components/database-restore-list";
|
import {DatabaseRestoreList} from "@/features/database/components/database-restore-list";
|
||||||
@@ -11,8 +11,6 @@ import {MemberWithUser} from "@/db/schema/03_organization";
|
|||||||
|
|
||||||
export type DatabaseTabsProps = {
|
export type DatabaseTabsProps = {
|
||||||
settings: Setting,
|
settings: Setting,
|
||||||
backups: BackupWith[],
|
|
||||||
restorations: Restoration[],
|
|
||||||
isAlreadyRestore: boolean,
|
isAlreadyRestore: boolean,
|
||||||
database: DatabaseWith,
|
database: DatabaseWith,
|
||||||
activeMember: MemberWithUser
|
activeMember: MemberWithUser
|
||||||
@@ -21,24 +19,27 @@ export type DatabaseTabsProps = {
|
|||||||
export const backupOnly = ["redis", "valkey"];
|
export const backupOnly = ["redis", "valkey"];
|
||||||
|
|
||||||
export const DatabaseTabs = (props: DatabaseTabsProps) => {
|
export const DatabaseTabs = (props: DatabaseTabsProps) => {
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "backup");
|
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "backup");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newTab = searchParams.get("tab") ?? "backup";
|
const onPopState = () => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
const params = new URLSearchParams(window.location.search);
|
||||||
setTab(newTab);
|
setTab(params.get("tab") ?? "backup");
|
||||||
}, [searchParams]);
|
};
|
||||||
|
window.addEventListener("popstate", onPopState);
|
||||||
|
return () => window.removeEventListener("popstate", onPopState);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleChangeTab = (value: string) => {
|
const handleChangeTab = (value: string) => {
|
||||||
router.push(`?tab=${value}`);
|
setTab(value);
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
params.set("tab", value);
|
||||||
|
window.history.pushState(null, "", `?${params.toString()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isBackupOnly = backupOnly.some((type) => props.database.dbms === type);
|
||||||
const isBackupOnly = backupOnly.some((type) => props.database.dbms === type)
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -47,7 +48,6 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => {
|
|||||||
isAlreadyRestore={props.isAlreadyRestore}
|
isAlreadyRestore={props.isAlreadyRestore}
|
||||||
settings={props.settings}
|
settings={props.settings}
|
||||||
database={props.database}
|
database={props.database}
|
||||||
backups={props.backups}
|
|
||||||
activeMember={props.activeMember}
|
activeMember={props.activeMember}
|
||||||
/>
|
/>
|
||||||
:
|
:
|
||||||
@@ -61,22 +61,18 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => {
|
|||||||
isAlreadyRestore={props.isAlreadyRestore}
|
isAlreadyRestore={props.isAlreadyRestore}
|
||||||
settings={props.settings}
|
settings={props.settings}
|
||||||
database={props.database}
|
database={props.database}
|
||||||
backups={props.backups}
|
|
||||||
activeMember={props.activeMember}
|
activeMember={props.activeMember}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent className="h-full justify-between" value="restore">
|
<TabsContent className="h-full justify-between" value="restore">
|
||||||
<DatabaseRestoreList
|
<DatabaseRestoreList
|
||||||
isAlreadyRestore={props.isAlreadyRestore}
|
isAlreadyRestore={props.isAlreadyRestore}
|
||||||
restorations={props.restorations}
|
|
||||||
activeMember={props.activeMember}
|
activeMember={props.activeMember}
|
||||||
databaseId={props.database.id}
|
databaseId={props.database.id}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
}
|
}
|
||||||
|
|
||||||
</>
|
</>
|
||||||
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const UploadBackupZone = ({onSuccessAction, database}: UploadRetentionZon
|
|||||||
toast.success(inner.actionSuccess?.message);
|
toast.success(inner.actionSuccess?.message);
|
||||||
onSuccessAction?.()
|
onSuccessAction?.()
|
||||||
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
|
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
|
||||||
|
queryClient.invalidateQueries({queryKey: ["backups", database.id]});
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast.error(inner?.actionError?.message);
|
toast.error(inner?.actionError?.message);
|
||||||
@@ -50,6 +51,7 @@ export const UploadBackupZone = ({onSuccessAction, database}: UploadRetentionZon
|
|||||||
toast.error("An error occurred while upload in the backup");
|
toast.error("An error occurred while upload in the backup");
|
||||||
} finally {
|
} finally {
|
||||||
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
|
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
|
||||||
|
queryClient.invalidateQueries({queryKey: ["backups", database.id]});
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -62,8 +62,7 @@ export function restoreColumns(
|
|||||||
accessorKey: "logs",
|
accessorKey: "logs",
|
||||||
header: "Logs",
|
header: "Logs",
|
||||||
cell: ({row}) => {
|
cell: ({row}) => {
|
||||||
const logs = row.original.logs ?? [];
|
return <LogsModalTrigger restorationId={row.original.id} hasLogs={row.original.hasLogs}/>;
|
||||||
return <LogsModalTrigger logs={logs}/>;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -86,6 +85,9 @@ export function restoreColumns(
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["database-data", rowData.databaseId],
|
queryKey: ["database-data", rowData.databaseId],
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["restorations", rowData.databaseId],
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
toast.error(restoration.data.actionError.message);
|
toast.error(restoration.data.actionError.message);
|
||||||
@@ -105,6 +107,9 @@ export function restoreColumns(
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["database-data", rowData.databaseId],
|
queryKey: ["database-data", rowData.databaseId],
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["restorations", rowData.databaseId],
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
toast.error(restoration.data.actionError.message);
|
toast.error(restoration.data.actionError.message);
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use server";
|
||||||
|
import { userAction } from "@/lib/safe-actions/actions";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import { JobLog } from "@/db/schema/17_job-log";
|
||||||
|
|
||||||
|
export const fetchJobLogsAction = userAction
|
||||||
|
.schema(
|
||||||
|
z.object({
|
||||||
|
backupId: z.string().optional(),
|
||||||
|
restorationId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.action(async ({ parsedInput }): Promise<JobLog[]> => {
|
||||||
|
const { backupId, restorationId } = parsedInput;
|
||||||
|
|
||||||
|
if (backupId) {
|
||||||
|
return (await db.query.jobLog.findMany({
|
||||||
|
where: eq(drizzleDb.schemas.jobLog.backupId, backupId),
|
||||||
|
orderBy: (l, { asc }) => [asc(l.loggedAt)],
|
||||||
|
})) as JobLog[];
|
||||||
|
}
|
||||||
|
if (restorationId) {
|
||||||
|
return (await db.query.jobLog.findMany({
|
||||||
|
where: eq(drizzleDb.schemas.jobLog.restorationId, restorationId),
|
||||||
|
orderBy: (l, { asc }) => [asc(l.loggedAt)],
|
||||||
|
})) as JobLog[];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
@@ -17,7 +17,7 @@ export const LogsModalProvider = ({children}: { children: ReactNode }) => {
|
|||||||
const [logs, setLogs] = useState<JobLog[]>([]);
|
const [logs, setLogs] = useState<JobLog[]>([]);
|
||||||
|
|
||||||
const openModal = (newLogs: JobLog[]) => {
|
const openModal = (newLogs: JobLog[]) => {
|
||||||
setLogs(newLogs);
|
setLogs(newLogs ?? []);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,35 @@
|
|||||||
import {FileText} from "lucide-react";
|
import {FileText, Loader2} from "lucide-react";
|
||||||
import {JobLog} from "@/db/schema/17_job-log";
|
import {useState} from "react";
|
||||||
|
import {toast} from "sonner";
|
||||||
import {Button} from "@/components/ui/button";
|
import {Button} from "@/components/ui/button";
|
||||||
import {useLogsModal} from "@/features/logs/components/logs-modal-context";
|
import {useLogsModal} from "@/features/logs/components/logs-modal-context";
|
||||||
|
import {fetchJobLogsAction} from "@/features/logs/actions/job-logs.action";
|
||||||
|
|
||||||
export type LogsModalTriggerProps = {
|
export type LogsModalTriggerProps = {
|
||||||
logs: JobLog[]
|
backupId?: string;
|
||||||
|
restorationId?: string;
|
||||||
|
hasLogs?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LogsModalTrigger = ({logs}: LogsModalTriggerProps) => {
|
export const LogsModalTrigger = ({backupId, restorationId, hasLogs}: LogsModalTriggerProps) => {
|
||||||
const {openModal} = useLogsModal();
|
const {openModal} = useLogsModal();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await fetchJobLogsAction({backupId, restorationId});
|
||||||
|
openModal(result?.data ?? []);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to load logs.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button disabled={logs.length == 0} variant="outline" size="sm" onClick={()=> {
|
<Button disabled={!hasLogs || isLoading} variant="outline" size="sm" onClick={handleClick}>
|
||||||
openModal(logs);
|
{isLoading ? <Loader2 className="animate-spin"/> : <FileText/>}
|
||||||
}}>
|
|
||||||
<FileText />
|
|
||||||
</Button>
|
</Button>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -29,11 +29,17 @@ export const LogsModal = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div>
|
{logs.length === 0 ? (
|
||||||
{logs.map((entry: JobLog) => (
|
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||||
<LogRow key={entry.id} entry={entry} />
|
No logs available
|
||||||
))}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div>
|
||||||
|
{logs.map((entry: JobLog) => (
|
||||||
|
<LogRow key={entry.id} entry={entry} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {ServerActionResult} from "@/types/action-type";
|
|||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {eq, inArray} from "drizzle-orm";
|
import {and, eq, inArray, isNull} from "drizzle-orm";
|
||||||
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
|
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
|
||||||
import {v4 as uuidv4} from "uuid";
|
import {v4 as uuidv4} from "uuid";
|
||||||
import {getTodayISODate} from "@/utils/date-formatting";
|
import {getTodayISODate} from "@/utils/date-formatting";
|
||||||
@@ -24,7 +24,10 @@ export const migrationAction = userAction.schema(
|
|||||||
try {
|
try {
|
||||||
|
|
||||||
const targetDatabase = await db.query.database.findFirst({
|
const targetDatabase = await db.query.database.findFirst({
|
||||||
where: eq(drizzleDb.schemas.database.id, targetDatabaseId),
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.database.id, targetDatabaseId),
|
||||||
|
isNull(drizzleDb.schemas.database.deletedAt),
|
||||||
|
),
|
||||||
with: {
|
with: {
|
||||||
project: true,
|
project: true,
|
||||||
retentionPolicy: true,
|
retentionPolicy: true,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export const STEP_IDS = {
|
|||||||
LOGIN: "login",
|
LOGIN: "login",
|
||||||
ACCOUNT_INFO: "account-info",
|
ACCOUNT_INFO: "account-info",
|
||||||
SECURITY: "security",
|
SECURITY: "security",
|
||||||
|
PREFERENCES: "preferences",
|
||||||
ORG_CREATE: "org-create",
|
ORG_CREATE: "org-create",
|
||||||
//INVITE_MEMBERS: "invite-members",
|
//INVITE_MEMBERS: "invite-members",
|
||||||
NOTIFIER: "notifier",
|
NOTIFIER: "notifier",
|
||||||
@@ -19,6 +20,7 @@ export const STEP_ORDER: string[] = [
|
|||||||
STEP_IDS.LOGIN,
|
STEP_IDS.LOGIN,
|
||||||
STEP_IDS.ACCOUNT_INFO,
|
STEP_IDS.ACCOUNT_INFO,
|
||||||
STEP_IDS.SECURITY,
|
STEP_IDS.SECURITY,
|
||||||
|
STEP_IDS.PREFERENCES,
|
||||||
STEP_IDS.ORG_CREATE,
|
STEP_IDS.ORG_CREATE,
|
||||||
//STEP_IDS.INVITE_MEMBERS,
|
//STEP_IDS.INVITE_MEMBERS,
|
||||||
STEP_IDS.NOTIFIER,
|
STEP_IDS.NOTIFIER,
|
||||||
|
|||||||
@@ -61,9 +61,9 @@ export const OnboardingChecklist = () => {
|
|||||||
|
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm pb-4",
|
"text-sm pb-4 ",
|
||||||
isCompleted && "text-zinc-400",
|
isCompleted && "text-zinc-400",
|
||||||
isCurrent && "text-white font-medium",
|
isCurrent && "text-primary font-medium",
|
||||||
!isCompleted && !isCurrent && "text-zinc-600",
|
!isCompleted && !isCurrent && "text-zinc-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
|
|||||||
|
|
||||||
const org = await getUserOwnOrganization(user.id);
|
const org = await getUserOwnOrganization(user.id);
|
||||||
if (!org) {
|
if (!org) {
|
||||||
meta.resumeStepId = "org-create";
|
meta.resumeStepId = "preferences";
|
||||||
return { stepId: "org-create", flowData: { meta } };
|
return { stepId: "preferences", flowData: { meta } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgData = { id: org.id, name: org.name };
|
const orgData = { id: org.id, name: org.name };
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { OnboardingStep } from "@onboardjs/react";
|
|||||||
import { StepLogin } from "@/features/onboarding/steps/step-login";
|
import { StepLogin } from "@/features/onboarding/steps/step-login";
|
||||||
import { StepAccountInfo } from "@/features/onboarding/steps/step-account-info";
|
import { StepAccountInfo } from "@/features/onboarding/steps/step-account-info";
|
||||||
import { StepSecurity } from "@/features/onboarding/steps/step-security";
|
import { StepSecurity } from "@/features/onboarding/steps/step-security";
|
||||||
|
import { StepPreferences } from "@/features/onboarding/steps/step-preferences";
|
||||||
import { StepOrgCreate } from "@/features/onboarding/steps/step-org-create";
|
import { StepOrgCreate } from "@/features/onboarding/steps/step-org-create";
|
||||||
import { StepInviteMembers } from "@/features/onboarding/steps/step-invite-members";
|
import { StepInviteMembers } from "@/features/onboarding/steps/step-invite-members";
|
||||||
import { StepNotifier } from "@/features/onboarding/steps/step-notifier";
|
import { StepNotifier } from "@/features/onboarding/steps/step-notifier";
|
||||||
@@ -20,19 +21,26 @@ export const onboardingSteps: OnboardingStep[] = [
|
|||||||
component: StepLogin,
|
component: StepLogin,
|
||||||
isSkippable: false,
|
isSkippable: false,
|
||||||
nextStep: (ctx: any) =>
|
nextStep: (ctx: any) =>
|
||||||
ctx.flowData?.meta?.hasExistingUsers ? "org-create" : "account-info",
|
ctx.flowData?.meta?.hasExistingUsers ? "preferences" : "account-info",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "account-info",
|
id: "account-info",
|
||||||
component: StepAccountInfo,
|
component: StepAccountInfo,
|
||||||
isSkippable: false,
|
isSkippable: false,
|
||||||
nextStep: (ctx: any) =>
|
nextStep: (ctx: any) =>
|
||||||
ctx.flowData?.meta?.passkeyEnabled ? "org-create" : "security",
|
ctx.flowData?.meta?.passkeyEnabled ? "preferences" : "security",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "security",
|
id: "security",
|
||||||
component: StepSecurity,
|
component: StepSecurity,
|
||||||
isSkippable: true,
|
isSkippable: true,
|
||||||
|
skipToStep: "preferences",
|
||||||
|
nextStep: "preferences",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "preferences",
|
||||||
|
component: StepPreferences,
|
||||||
|
isSkippable: true,
|
||||||
skipToStep: "org-create",
|
skipToStep: "org-create",
|
||||||
nextStep: "org-create",
|
nextStep: "org-create",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -70,7 +70,16 @@ export const StepAgentCreate = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="button" onClick={() => next()}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => next()}
|
||||||
|
disabled={!!name.trim() || createAgent.isPending}
|
||||||
|
title={
|
||||||
|
name.trim()
|
||||||
|
? "Add or clear the agent name before continuing"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
Continue
|
Continue
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export const StepDefaults = () => {
|
|||||||
const [storageId, setStorageId] = useState<string | undefined>(
|
const [storageId, setStorageId] = useState<string | undefined>(
|
||||||
existingDefaults.storageId || undefined,
|
existingDefaults.storageId || undefined,
|
||||||
);
|
);
|
||||||
|
const [notifierOpen, setNotifierOpen] = useState(false);
|
||||||
|
const [storageOpen, setStorageOpen] = useState(false);
|
||||||
|
|
||||||
const selectNotifier = async (value: string) => {
|
const selectNotifier = async (value: string) => {
|
||||||
const prev = notifierId;
|
const prev = notifierId;
|
||||||
@@ -56,6 +58,26 @@ export const StepDefaults = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearNotifier = async () => {
|
||||||
|
const prev = notifierId;
|
||||||
|
setNotifierId(undefined);
|
||||||
|
try {
|
||||||
|
await updateNotificationSettingsAction({
|
||||||
|
name: "system",
|
||||||
|
data: { notificationChannelId: null },
|
||||||
|
});
|
||||||
|
await updateContext({
|
||||||
|
flowData: {
|
||||||
|
...state?.context.flowData,
|
||||||
|
defaults: { notifierId: undefined, storageId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setNotifierId(prev);
|
||||||
|
toast.error("Failed to clear default notifier");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const selectStorage = async (value: string) => {
|
const selectStorage = async (value: string) => {
|
||||||
const prev = storageId;
|
const prev = storageId;
|
||||||
setStorageId(value);
|
setStorageId(value);
|
||||||
@@ -76,6 +98,26 @@ export const StepDefaults = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearStorage = async () => {
|
||||||
|
const prev = storageId;
|
||||||
|
setStorageId(undefined);
|
||||||
|
try {
|
||||||
|
await updateStorageSettingsAction({
|
||||||
|
name: "system",
|
||||||
|
data: { storageChannelId: null, encryption: false },
|
||||||
|
});
|
||||||
|
await updateContext({
|
||||||
|
flowData: {
|
||||||
|
...state?.context.flowData,
|
||||||
|
defaults: { notifierId, storageId: undefined },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setStorageId(prev);
|
||||||
|
toast.error("Failed to clear default storage");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onContinue = async () => {
|
const onContinue = async () => {
|
||||||
await updateContext({
|
await updateContext({
|
||||||
flowData: {
|
flowData: {
|
||||||
@@ -102,8 +144,10 @@ export const StepDefaults = () => {
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Default notifier</Label>
|
<Label>Default notifier</Label>
|
||||||
<Select
|
<Select
|
||||||
value={selectedNotifier ? notifierId : undefined}
|
value={selectedNotifier ? notifierId : ""}
|
||||||
onValueChange={selectNotifier}
|
onValueChange={selectNotifier}
|
||||||
|
open={notifierOpen}
|
||||||
|
onOpenChange={setNotifierOpen}
|
||||||
disabled={notifiers.length === 0}
|
disabled={notifiers.length === 0}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
@@ -128,7 +172,17 @@ export const StepDefaults = () => {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{notifiers.map((n) => (
|
{notifiers.map((n) => (
|
||||||
<SelectItem key={n.id} value={n.id}>
|
<SelectItem
|
||||||
|
key={n.id}
|
||||||
|
value={n.id}
|
||||||
|
onPointerUp={(e) => {
|
||||||
|
if (n.id === notifierId) {
|
||||||
|
e.preventDefault();
|
||||||
|
clearNotifier();
|
||||||
|
setNotifierOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2 w-full min-w-0">
|
<div className="flex items-center gap-2 w-full min-w-0">
|
||||||
<div className="text-muted-foreground scale-90 shrink-0">
|
<div className="text-muted-foreground scale-90 shrink-0">
|
||||||
{getChannelIcon(n.provider)}
|
{getChannelIcon(n.provider)}
|
||||||
@@ -149,8 +203,10 @@ export const StepDefaults = () => {
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Default storage</Label>
|
<Label>Default storage</Label>
|
||||||
<Select
|
<Select
|
||||||
value={selectedStorage ? storageId : undefined}
|
value={selectedStorage ? storageId : ""}
|
||||||
onValueChange={selectStorage}
|
onValueChange={selectStorage}
|
||||||
|
open={storageOpen}
|
||||||
|
onOpenChange={setStorageOpen}
|
||||||
disabled={storages.length === 0}
|
disabled={storages.length === 0}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
@@ -175,7 +231,17 @@ export const StepDefaults = () => {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{storages.map((s) => (
|
{storages.map((s) => (
|
||||||
<SelectItem key={s.id} value={s.id}>
|
<SelectItem
|
||||||
|
key={s.id}
|
||||||
|
value={s.id}
|
||||||
|
onPointerUp={(e) => {
|
||||||
|
if (s.id === storageId) {
|
||||||
|
e.preventDefault();
|
||||||
|
clearStorage();
|
||||||
|
setStorageOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2 w-full min-w-0">
|
<div className="flex items-center gap-2 w-full min-w-0">
|
||||||
<div className="text-muted-foreground scale-90 shrink-0">
|
<div className="text-muted-foreground scale-90 shrink-0">
|
||||||
{getChannelIcon(s.provider)}
|
{getChannelIcon(s.provider)}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useOnboarding } from "@onboardjs/react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { authClient } from "@/lib/auth/auth-client";
|
||||||
|
import { ThemeKey, ThemeSelector } from "@/components/common/theme-selector";
|
||||||
|
|
||||||
|
export const StepPreferences = () => {
|
||||||
|
const { next, updateContext, state } = useOnboarding();
|
||||||
|
const { theme: currentTheme, setTheme } = useTheme();
|
||||||
|
const preferences = state?.context.flowData.preferences ?? {
|
||||||
|
theme: (currentTheme ?? "system") as ThemeKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectTheme = async (theme: ThemeKey) => {
|
||||||
|
setTheme(theme);
|
||||||
|
try {
|
||||||
|
await authClient.updateUser({ theme });
|
||||||
|
await updateContext({
|
||||||
|
flowData: {
|
||||||
|
...state?.context.flowData,
|
||||||
|
preferences: { ...preferences, theme },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to save theme preference");
|
||||||
|
setTheme(preferences.theme as ThemeKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onContinue = async () => {
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Your preferences</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Optional — personalise your workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-sm font-medium">Theme</p>
|
||||||
|
<ThemeSelector value={preferences.theme} onSelect={selectTheme} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="button" onClick={onContinue}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,7 +5,7 @@ import {ProjectSchema} from "@/features/projects/schemas/projects.schema";
|
|||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
import {ServerActionResult} from "@/types/action-type";
|
import {ServerActionResult} from "@/types/action-type";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {and, eq, inArray} from "drizzle-orm";
|
import {and, eq, inArray, isNull} from "drizzle-orm";
|
||||||
import {Project} from "@/db/schema/06_project";
|
import {Project} from "@/db/schema/06_project";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {Database} from "@/db/schema/07_database";
|
import {Database} from "@/db/schema/07_database";
|
||||||
@@ -88,7 +88,9 @@ export const updateProjectAction = userAction
|
|||||||
const existing = await db.query.project.findFirst({
|
const existing = await db.query.project.findFirst({
|
||||||
where: eq(drizzleDb.schemas.project.id, parsedInput.projectId),
|
where: eq(drizzleDb.schemas.project.id, parsedInput.projectId),
|
||||||
with: {
|
with: {
|
||||||
databases: true,
|
databases: {
|
||||||
|
where: isNull(drizzleDb.schemas.database.deletedAt),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {DatabaseZap, RotateCcw, X, Loader2} from "lucide-react";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
|
||||||
|
export type BulkActionBarProps = {
|
||||||
|
count: number;
|
||||||
|
isPending: boolean;
|
||||||
|
onBackup: () => void;
|
||||||
|
onRestore: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BulkActionBar = ({count, isPending, onBackup, onRestore, onClear}: BulkActionBarProps) => {
|
||||||
|
if (count === 0) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed bottom-4 sm:bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-2 sm:gap-3 rounded-xl border border-primary/50 bg-card shadow-lg px-3 sm:px-4 py-2 sm:py-3 max-w-[calc(100vw-1.5rem)]">
|
||||||
|
<span className="flex-shrink-0 rounded-full bg-primary text-primary-foreground text-xs font-bold px-2.5 sm:px-3 py-1">
|
||||||
|
{count}
|
||||||
|
<span className="hidden sm:inline"> selected</span>
|
||||||
|
</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={onBackup} disabled={isPending} title="Backup">
|
||||||
|
{isPending ? <Loader2 className="animate-spin"/> : <DatabaseZap/>}
|
||||||
|
<span className="hidden sm:inline">Backup</span>
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={onRestore} disabled={isPending} title="Restore latest">
|
||||||
|
<RotateCcw/>
|
||||||
|
<span className="hidden sm:inline">Restore latest</span>
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={onClear} disabled={isPending} title="Clear">
|
||||||
|
<X/>
|
||||||
|
<span className="hidden sm:inline">Clear</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {useState} from "react";
|
||||||
|
import {Loader2} from "lucide-react";
|
||||||
|
import {Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter} from "@/components/ui/dialog";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {Input} from "@/components/ui/input";
|
||||||
|
import type {RestorePreviewRow} from "@/features/database/actions/bulk-restore.action";
|
||||||
|
|
||||||
|
const CONFIRM_WORD = "restore";
|
||||||
|
|
||||||
|
export type BulkRestoreModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
submitting: boolean;
|
||||||
|
rows: RestorePreviewRow[];
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BulkRestoreModal = (props: BulkRestoreModalProps) => {
|
||||||
|
const {open, loading, submitting, rows, onConfirm, onCancel} = props;
|
||||||
|
const [typed, setTyped] = useState("");
|
||||||
|
|
||||||
|
const restorableCount = rows.filter((r) => r.restorable).length;
|
||||||
|
const canConfirm = restorableCount > 0 && typed.trim().toLowerCase() === CONFIRM_WORD && !submitting;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Restore {rows.length} database(s) to latest backup
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This overwrites current data and cannot be undone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8"><Loader2 className="animate-spin" /></div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-64 overflow-auto rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-muted-foreground border-b">
|
||||||
|
<th className="p-2">Database</th>
|
||||||
|
<th className="p-2">Will restore from</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.databaseId} className="border-b last:border-0">
|
||||||
|
<td className={`p-2 ${r.restorable ? "" : "opacity-50"}`}>{r.name}</td>
|
||||||
|
<td className="p-2">
|
||||||
|
{r.restorable
|
||||||
|
? <span className="text-green-600">{r.backupDate ? new Date(r.backupDate).toLocaleString() : "latest backup"}</span>
|
||||||
|
: <span className="text-amber-500">{r.reason ?? "no successful backup"} — skipped</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{restorableCount > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Type "{CONFIRM_WORD}" to confirm</label>
|
||||||
|
<Input value={typed} onChange={(e) => setTyped(e.target.value)} placeholder={CONFIRM_WORD} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onCancel} disabled={submitting}>Cancel</Button>
|
||||||
|
<Button onClick={onConfirm} disabled={!canConfirm}>
|
||||||
|
{submitting && <Loader2 className="animate-spin" />}
|
||||||
|
{restorableCount > 0 ? `Restore ${restorableCount} database(s)` : "Nothing to restore"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {useMemo, useState} from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {useRouter} from "next/navigation";
|
||||||
|
import {useQueryClient} from "@tanstack/react-query";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
|
||||||
|
import {Database} from "@/db/schema/07_database";
|
||||||
|
import {DatabaseCard} from "@/components/common/database-card";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {PaginationNavigation} from "@/components/common/pagination-navigation";
|
||||||
|
import {PaginationSize} from "@/components/common/pagination-size";
|
||||||
|
import {BulkActionBar} from "@/features/projects/components/bulk-action-bar";
|
||||||
|
import {BulkRestoreModal} from "@/features/projects/components/bulk-restore-modal";
|
||||||
|
import {bulkBackupAction} from "@/features/database/actions/bulk-backup.action";
|
||||||
|
import {
|
||||||
|
bulkRestorePreviewAction,
|
||||||
|
bulkRestoreLatestAction,
|
||||||
|
type RestorePreviewRow,
|
||||||
|
} from "@/features/database/actions/bulk-restore.action";
|
||||||
|
|
||||||
|
export type ProjectDatabaseSelectionProps = {
|
||||||
|
projectId: string;
|
||||||
|
databases: Database[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProjectDatabaseSelection = ({projectId, databases}: ProjectDatabaseSelectionProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
const [restoreOpen, setRestoreOpen] = useState(false);
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
const [previewRows, setPreviewRows] = useState<RestorePreviewRow[]>([]);
|
||||||
|
|
||||||
|
const sorted = useMemo(
|
||||||
|
() => [...databases].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
|
||||||
|
[databases],
|
||||||
|
);
|
||||||
|
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||||||
|
const pageItems = sorted.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||||
|
const selectedIds = Array.from(selected);
|
||||||
|
|
||||||
|
const toggle = (id: string) =>
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectAll = () => setSelected(new Set(sorted.map((d) => d.id)));
|
||||||
|
const clear = () => setSelected(new Set());
|
||||||
|
|
||||||
|
const afterQueue = () => {
|
||||||
|
clear();
|
||||||
|
queryClient.invalidateQueries({queryKey: ["database-data"]});
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackup = async () => {
|
||||||
|
setPending(true);
|
||||||
|
const res = await bulkBackupAction({projectId, databaseIds: selectedIds});
|
||||||
|
setPending(false);
|
||||||
|
const data = res?.data;
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success(data.actionSuccess?.message ?? "Backups queued.");
|
||||||
|
afterQueue();
|
||||||
|
} else {
|
||||||
|
toast.error(res?.serverError ?? data?.actionError?.message ?? "Failed to queue backups.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRestore = async () => {
|
||||||
|
setRestoreOpen(true);
|
||||||
|
setPreviewLoading(true);
|
||||||
|
const res = await bulkRestorePreviewAction({projectId, databaseIds: selectedIds});
|
||||||
|
setPreviewLoading(false);
|
||||||
|
if (res?.data?.success && res.data.value) {
|
||||||
|
setPreviewRows(res.data.value);
|
||||||
|
} else {
|
||||||
|
toast.error(res?.serverError ?? "Failed to build preview.");
|
||||||
|
setRestoreOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmRestore = async () => {
|
||||||
|
setPending(true);
|
||||||
|
const res = await bulkRestoreLatestAction({projectId, databaseIds: selectedIds});
|
||||||
|
setPending(false);
|
||||||
|
setRestoreOpen(false);
|
||||||
|
const data = res?.data;
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success(data.actionSuccess?.message ?? "Restores queued.");
|
||||||
|
afterQueue();
|
||||||
|
} else {
|
||||||
|
toast.error(res?.serverError ?? data?.actionError?.message ?? "Failed to queue restores.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm text-muted-foreground">{selected.size} selected</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={selectAll}>Select all</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={clear} disabled={selected.size === 0}>Clear</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid h-max auto-rows-min gap-4 md:grid-cols-3">
|
||||||
|
{pageItems.map((database) => (
|
||||||
|
<Link
|
||||||
|
key={database.id}
|
||||||
|
className="group block transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
|
||||||
|
href={`/dashboard/projects/${projectId}/database/${database.id}`}
|
||||||
|
>
|
||||||
|
<DatabaseCard
|
||||||
|
data={database}
|
||||||
|
selectable
|
||||||
|
selected={selected.has(database.id)}
|
||||||
|
onToggleSelect={toggle}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end mt-4 gap-4">
|
||||||
|
<PaginationSize
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
pageSizeOptions={[10, 20, 50]}
|
||||||
|
/>
|
||||||
|
<PaginationNavigation
|
||||||
|
className="justify-end"
|
||||||
|
totalPages={totalPages}
|
||||||
|
currentPage={currentPage}
|
||||||
|
goToPage={setCurrentPage}
|
||||||
|
goToPrevPage={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||||
|
goToNextPage={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||||
|
maxVisiblePages={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BulkActionBar
|
||||||
|
count={selected.size}
|
||||||
|
isPending={pending}
|
||||||
|
onBackup={handleBackup}
|
||||||
|
onRestore={openRestore}
|
||||||
|
onClear={clear}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BulkRestoreModal
|
||||||
|
open={restoreOpen}
|
||||||
|
loading={previewLoading}
|
||||||
|
submitting={pending}
|
||||||
|
rows={previewRows}
|
||||||
|
onConfirm={confirmRestore}
|
||||||
|
onCancel={() => setRestoreOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -24,7 +24,7 @@ export const updateStorageSettingsAction = userAction
|
|||||||
const [updatedSettings] = await db
|
const [updatedSettings] = await db
|
||||||
.update(drizzleDb.schemas.setting)
|
.update(drizzleDb.schemas.setting)
|
||||||
.set(withUpdatedAt({
|
.set(withUpdatedAt({
|
||||||
defaultStorageChannelId: data.storageChannelId,
|
defaultStorageChannelId: data.storageChannelId ?? null,
|
||||||
encryption: data.encryption,
|
encryption: data.encryption,
|
||||||
}))
|
}))
|
||||||
.where(eq(drizzleDb.schemas.setting.name, name))
|
.where(eq(drizzleDb.schemas.setting.name, name))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
|
|
||||||
export const DefaultStorageSchema = z.object({
|
export const DefaultStorageSchema = z.object({
|
||||||
storageChannelId: z.string(),
|
storageChannelId: z.string().optional().nullable(),
|
||||||
encryption: z.boolean(),
|
encryption: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,12 @@ const themes = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
export function ModeToggle() {
|
export function ModeToggle() {
|
||||||
const { theme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
useEffect(() => setMounted(true), [])
|
useEffect(() => setMounted(true), [])
|
||||||
|
|
||||||
const handleThemeChange = async (newTheme: "light" | "system" | "dark") => {
|
const handleThemeChange = async (newTheme: "light" | "system" | "dark") => {
|
||||||
|
setTheme(newTheme)
|
||||||
await authClient.updateUser({ theme: newTheme })
|
await authClient.updateUser({ theme: newTheme })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
|
import type { PaginationState, SortingState } from "@tanstack/react-table";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface PaginatedResult<TData> {
|
||||||
|
data: TData[];
|
||||||
|
meta: {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseServerDataTableProps<TData, TParams> {
|
||||||
|
fetchAction: any;
|
||||||
|
queryKey: any[];
|
||||||
|
extraParams?: Partial<TParams>;
|
||||||
|
initialPageSize?: number;
|
||||||
|
initialSorting?: SortingState;
|
||||||
|
enabled?: boolean;
|
||||||
|
refetchInterval?: number | false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_ARRAY: any[] = [];
|
||||||
|
|
||||||
|
export function useServerDataTable<TData, TParams>(
|
||||||
|
props: UseServerDataTableProps<TData, TParams>,
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
fetchAction,
|
||||||
|
queryKey,
|
||||||
|
extraParams,
|
||||||
|
initialPageSize = 20,
|
||||||
|
initialSorting = [],
|
||||||
|
enabled = true,
|
||||||
|
refetchInterval = false,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: initialPageSize,
|
||||||
|
});
|
||||||
|
const [sorting, setSorting] = useState<SortingState>(initialSorting);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||||
|
}, [sorting]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||||
|
}, [JSON.stringify(extraParams)]);
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
...queryKey,
|
||||||
|
pagination.pageIndex,
|
||||||
|
pagination.pageSize,
|
||||||
|
sorting,
|
||||||
|
JSON.stringify(extraParams),
|
||||||
|
],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetchAction({
|
||||||
|
page: pagination.pageIndex + 1,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
sorting,
|
||||||
|
...extraParams,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const result = response?.data as PaginatedResult<TData> | undefined;
|
||||||
|
if (!result || !Array.isArray(result.data)) {
|
||||||
|
throw new Error("Failed to fetch paginated data");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
enabled,
|
||||||
|
gcTime: 2 * 60 * 1000,
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
refetchInterval,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: (query.data?.data as TData[]) || EMPTY_ARRAY,
|
||||||
|
rowCount: query.data?.meta?.total || 0,
|
||||||
|
pagination,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
sorting,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
tableProps: {
|
||||||
|
manualPagination: true as const,
|
||||||
|
rowCount: query.data?.meta?.total || 0,
|
||||||
|
paginationState: pagination,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
sorting,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
},
|
||||||
|
meta: query.data?.meta,
|
||||||
|
isLoading: query.isPending,
|
||||||
|
isFetching: query.isFetching,
|
||||||
|
refetch: query.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import {ApiKeyContextUser} from "@/lib/api-v1/types";
|
import {ApiKeyContextUser} from "@/lib/api-v1/types";
|
||||||
import {getAccessibleDatabaseIds} from "@/lib/api-v1/services/databases";
|
import {getAccessibleDatabaseIds} from "@/lib/api-v1/services/databases";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {eq} from "drizzle-orm";
|
import {and, eq, isNull} from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
|
|
||||||
export async function resolveDatabaseAccess(id: string, user: ApiKeyContextUser) {
|
export async function resolveDatabaseAccess(id: string, user: ApiKeyContextUser) {
|
||||||
const accessibleIds = await getAccessibleDatabaseIds(user);
|
const accessibleIds = await getAccessibleDatabaseIds(user);
|
||||||
if (accessibleIds.includes(id)) return "ok";
|
if (accessibleIds.includes(id)) return "ok";
|
||||||
const exists = await db.query.database.findFirst({
|
const exists = await db.query.database.findFirst({
|
||||||
where: eq(drizzleDb.schemas.database.id, id),
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.database.id, id),
|
||||||
|
isNull(drizzleDb.schemas.database.deletedAt),
|
||||||
|
),
|
||||||
columns: { id: true },
|
columns: { id: true },
|
||||||
});
|
});
|
||||||
return exists ? "forbidden" : "not_found";
|
return exists ? "forbidden" : "not_found";
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {and, eq, isNotNull, isNull} from "drizzle-orm";
|
import {and, eq, isNotNull, isNull, lt} from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {withUpdatedAt} from "@/db/utils";
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
import {logger} from "@/lib/logger";
|
import {logger} from "@/lib/logger";
|
||||||
|
import {env} from "@/env.mjs";
|
||||||
|
import {sendNotificationsBackupRestore} from "@/features/notifications/utils/notifications.helpers";
|
||||||
|
|
||||||
const log = logger.child({module: "tasks/cleaning"});
|
const log = logger.child({module: "tasks/cleaning"});
|
||||||
|
|
||||||
@@ -23,6 +25,39 @@ export const backupCleanTask = async () => {
|
|||||||
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const staleCutoff = new Date(Date.now() - env.STALE_BACKUP_THRESHOLD_HOURS * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const staleOngoingBackups = await db.query.backup.findMany({
|
||||||
|
where: and(
|
||||||
|
isNull(drizzleDb.schemas.backup.deletedAt),
|
||||||
|
eq(drizzleDb.schemas.backup.status, "ongoing"),
|
||||||
|
lt(drizzleDb.schemas.backup.createdAt, staleCutoff)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
log.debug(`Stale ongoing backups to fail: ${staleOngoingBackups.length}`);
|
||||||
|
|
||||||
|
for (const backup of staleOngoingBackups) {
|
||||||
|
await db.update(drizzleDb.schemas.backup).set(withUpdatedAt({
|
||||||
|
status: "failed",
|
||||||
|
}))
|
||||||
|
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const database = await db.query.database.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.database.id, backup.databaseId),
|
||||||
|
isNull(drizzleDb.schemas.database.deletedAt),
|
||||||
|
),
|
||||||
|
with: {alertPolicies: true},
|
||||||
|
});
|
||||||
|
if (database) {
|
||||||
|
await sendNotificationsBackupRestore(database, "error_backup");
|
||||||
|
}
|
||||||
|
} catch (notifyError) {
|
||||||
|
log.error({name: "backupCleanTask", error: notifyError}, "Stale backup notification failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const failedBackups = await db.query.backup.findMany({
|
const failedBackups = await db.query.backup.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
isNull(drizzleDb.schemas.backup.deletedAt),
|
isNull(drizzleDb.schemas.backup.deletedAt),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const log = logger.child({ module: "tasks/database" });
|
|||||||
export const retentionCleanTask = async () => {
|
export const retentionCleanTask = async () => {
|
||||||
try {
|
try {
|
||||||
const databases = await db.query.database.findMany({
|
const databases = await db.query.database.findMany({
|
||||||
|
where: isNull(drizzleDb.schemas.database.deletedAt),
|
||||||
with: {
|
with: {
|
||||||
retentionPolicy: true,
|
retentionPolicy: true,
|
||||||
backups: {
|
backups: {
|
||||||
|
|||||||
Reference in New Issue
Block a user