Compare commits

..
15 Commits
Author SHA1 Message Date
github-actions[bot] dc80d1d752 chore: release 1.13.0 2026-04-29 20:40:27 +00:00
ff28a80d7c feat: migration-tool (#269)
* feat: working on migration backup tool

* feat: add migration sequences

* fix: responsive for migration tool

* fix: truncate database name in migration

* fix: filter on databases success for migration tool

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-04-29 22:39:37 +02:00
github-actions[bot] ab55ab349a chore: release 1.12.1 2026-04-24 19:43:11 +00:00
Charles GTEandGitHub a1ec7dbec2 fix: migration error on 2fa (#268) 2026-04-24 21:42:20 +02:00
github-actions[bot] e29846ae25 chore: release 1.12.0 2026-04-10 16:38:28 +00:00
e38519aec2 feat: org agent management (#259)
* feat: org agent management

* feat: org agent management

* fix: adding migrations for legacy and refactoring.

* fix: refactoring

* fix: delete agent

* fix: refactoring

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-04-10 18:37:35 +02:00
github-actions[bot] a620d7a9f7 chore: release 1.11.5 2026-04-06 19:24:12 +00:00
Charles GTEandGitHub 88ebd79681 fix: username capitalization in profile modal (#257) 2026-04-06 21:23:36 +02:00
github-actions[bot] e08efa12fa chore: release 1.11.4 2026-04-06 19:09:07 +00:00
Charles GTEandGitHub 7a685c0518 fix: home project counter (#256)
* fix: home project counter

* fix: home project counter
2026-04-06 21:08:29 +02:00
github-actions[bot] 4deaaa2c8e chore: release 1.11.3 2026-04-06 18:34:59 +00:00
Charles GTEandGitHub adaf0f89fb fix: pipeline ci (#255) 2026-04-06 20:34:24 +02:00
47de5373b1 fix: add page size selector to CardsWithPagination (#253)
* fix: add page size selector to CardsWithPagination

Add an optional pageSizeOptions prop to CardsWithPagination that renders
a "Cards per page" dropdown selector, consistent with the existing
TablePaginationSize pattern used in DataTable.

- New PaginationSize component (standalone, no TanStack Table dependency)
- CardsWithPagination: manage pageSize as state, reset to page 1 on change
- Project databases page: increase default from 6 to 20, add selector [10, 20, 50]
- Projects list page: increase default from 9 to 12, add selector [12, 24, 48]
- Fully backward compatible: without pageSizeOptions, behavior is unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address CodeRabbit review feedback

- Validate pageSize in handlePageSizeChange (reject 0/negative/NaN)
- Guard empty pageSizeOptions array from rendering empty selector
- Restore className on PaginationNavigation for backward compatibility
- Add aria-label to SelectTrigger for accessibility on small screens

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(pagination-size): fallback to pageSizeOptions[0] when pageSize not in options

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(pagination-size): fix pageSize is not defined (placeholder ref)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 20:16:15 +02:00
github-actions[bot] db9d23b5ec chore: release 1.11.2 2026-04-02 17:47:39 +00:00
47c74931d4 fix: upload file database extension (#250)
* fix: upload file database

* fix: getFileHeadersBasedOnDbms for default if no dbType compatible

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-04-02 19:47:05 +02:00
83 changed files with 17721 additions and 2323 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
name: Auto Release & Publish
on:
pull_request:
pull_request_target:
types: [ closed ]
branches:
- main
+1 -1
View File
@@ -31,5 +31,5 @@ keywords:
- web-ui
- agent
license: Apache-2.0
version: 1.11.1
version: 1.13.0
date-released: '2026-03-02'
+1 -1
View File
@@ -16,7 +16,7 @@ seed-pocket:
@docker compose -f docker-compose.func.yml stop pocket-id >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml rm -f -s pocket-id >/dev/null 2>&1 || true
@docker volume rm portabase-dev-func_pocket-id-data >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml run --rm -v ./seeds/pocket-id/portabase.zip:/tmp/portabase.zip pocket-id ./pocket-id import --yes --path /tmp/portabase.zip >/dev/null
@docker compose -f docker-compose.func.yml run --rm -v $$(pwd)/seeds/pocket-id/portabase.zip:/tmp/portabase.zip pocket-id ./pocket-id import --yes --path /tmp/portabase.zip >/dev/null
@docker compose -f docker-compose.func.yml up -d pocket-id
@sleep 2
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin
@@ -7,7 +7,7 @@ import {
} from "@/features/layout/page";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { eq } from "drizzle-orm";
import {eq, isNull} from "drizzle-orm";
import { notFound } from "next/navigation";
import { ButtonDeleteAgent } from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
import { capitalizeFirstLetter } from "@/utils/text";
@@ -15,7 +15,7 @@ import { generateEdgeKey } from "@/utils/edge_key";
import { getServerUrl } from "@/utils/get-server-url";
import { AgentContentPage } from "@/components/wrappers/dashboard/agent/agent-content";
import { AgentDialog } from "@/features/agents/components/agent.dialog";
import { AgentType } from "@/features/agents/agents.schema";
export default async function RoutePage(
props: PageParams<{ agentId: string }>,
@@ -26,13 +26,30 @@ export default async function RoutePage(
where: eq(drizzleDb.schemas.agent.id, agentId),
with: {
databases: true,
organizations: true,
},
});
const organizations = await db.query.organization.findMany({
where: (fields) => isNull(fields.deletedAt),
with: {
members: true,
},
});
if (!agent) {
notFound();
}
const isOwnerByAnOrganization = agent.organizationId
if (isOwnerByAnOrganization){
notFound();
}
const organizationIds = agent.organizations.map(org => org.organizationId)
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
return (
@@ -45,12 +62,14 @@ export default async function RoutePage(
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<AgentDialog
agent={agent as AgentType & { id: string }}
agent={agent}
typeTrigger={"edit"}
adminView={true}
organizations={organizations}
/>
</div>
<div className="flex items-center gap-2">
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"} />
<ButtonDeleteAgent organizationIds={organizationIds} agentId={agentId} text={"Delete Agent"} />
</div>
</div>
</PageTitle>
@@ -5,7 +5,7 @@ import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/
import {notFound} from "next/navigation";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {desc, eq, not} from "drizzle-orm";
import {and, desc, eq, isNull, not} from "drizzle-orm";
import {Metadata} from "next";
import {AgentDialog} from "@/features/agents/components/agent.dialog";
@@ -16,13 +16,13 @@ export const metadata: Metadata = {
export default async function RoutePage(props: PageParams<{}>) {
const agents = await db.query.agent.findMany({
where: not(eq(drizzleDb.schemas.agent.isArchived, true)),
where: and(not(eq(drizzleDb.schemas.agent.isArchived, true)),isNull(drizzleDb.schemas.agent.organizationId)),
with: {
databases: true
},
orderBy: (fields) => desc(fields.lastContact),
});
if (!agents) {
notFound();
}
@@ -0,0 +1,65 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {notFound} from "next/navigation";
import {getOrganization} from "@/lib/auth/auth";
import {Metadata} from "next";
import {db} from "@/db";
import {MigrationTool} from "@/components/wrappers/dashboard/organization/migration/migration-tool";
export const metadata: Metadata = {
title: "Projects",
};
export default async function RoutePage(props: PageParams<{}>) {
const organization = await getOrganization({});
if (!organization) {
notFound();
}
const projects = await db.query.project.findMany({
where: (project, {eq, and, not}) =>
and(
eq(project.organizationId, organization.id),
not(eq(project.isArchived, true))
),
with: {
organization: true,
databases: {
where: (database, { isNull, not, inArray, and }) =>
and(
isNull(database.deletedAt),
not(inArray(database.dbms, ["valkey", "redis"]))
),
with: {
backups: {
where: (backup, { isNull, eq, and }) =>
and(
isNull(backup.deletedAt),
eq(backup.status, "success")
),
orderBy: (backup, {desc}) => [desc(backup.createdAt)],
limit: 15,
}
}
},
},
});
return (
<Page>
<PageHeader className="flex flex-col items-start justify-between mb-6">
<PageTitle className="mb-2">
Database Migration
</PageTitle>
<p className="text-sm text-muted-foreground">
Import backups from a source project into your target database
</p>
</PageHeader>
<PageContent>
<MigrationTool projects={projects}/>
</PageContent>
</Page>
);
}
@@ -6,16 +6,15 @@ import {
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {ProjectDatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
import {notFound, redirect} from "next/navigation";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import * as drizzleDb from "@/db";
import {capitalizeFirstLetter} from "@/utils/text";
import {ProjectDialog} from "@/features/projects/components/project.dialog";
import {DatabaseWith} from "@/db/schema/07_database";
import {ProjectWith} from "@/db/schema/06_project";
import {isUuidv4} from "@/utils/verify-uuid";
import {getOrganizationAvailableDatabases} from "@/db/services/database";
export default async function RoutePage(props: PageParams<{
@@ -56,19 +55,7 @@ export default async function RoutePage(props: PageParams<{
redirect("/dashboard/projects");
}
const availableDatabases = (
await db.query.database.findMany({
where: (db, {or, eq, isNull}) => or(isNull(db.projectId), eq(db.projectId, proj.id)),
with: {
agent: true,
project: true,
backups: true,
restorations: true,
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
) as DatabaseWith[];
const availableDatabases = await getOrganizationAvailableDatabases(organization.id, proj.id)
const isMember = activeMember?.role === "member";
return (
@@ -102,8 +89,9 @@ export default async function RoutePage(props: PageParams<{
organizationSlug={organization.slug}
// @ts-ignore
cardItem={ProjectDatabaseCard}
cardsPerPage={6}
cardsPerPage={20}
numberOfColumns={3}
pageSizeOptions={[10, 20, 50]}
extendedProps={proj}
/>
) : (
@@ -9,6 +9,7 @@ import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-pl
import {Metadata} from "next";
import {ProjectDialog} from "@/features/projects/components/project.dialog";
import {DatabaseWith} from "@/db/schema/07_database";
import {getOrganizationAvailableDatabases} from "@/db/services/database";
export const metadata: Metadata = {
title: "Projects",
@@ -35,18 +36,7 @@ export default async function RoutePage(props: PageParams<{}>) {
});
const isMember = activeMember?.role === "member";
const availableDatabases = (
await db.query.database.findMany({
where: (db, {isNull}) => isNull(db.projectId),
with: {
agent: true,
project: true,
backups: true,
restorations: true,
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
).filter((db) => db.project == null) as DatabaseWith[];
const availableDatabases = await getOrganizationAvailableDatabases(organization.id)
return (
@@ -66,11 +56,12 @@ export default async function RoutePage(props: PageParams<{}>) {
organizationSlug={organization.slug}
data={projects}
cardItem={ProjectCard}
cardsPerPage={9}
cardsPerPage={12}
numberOfColumns={3}
pageSizeOptions={[12, 24, 48]}
/>
) : isMember ? (
<EmptyStatePlaceholder text="No project available"/>
<EmptyStatePlaceholder state={"empty"} text="No project available"/>
) : (
<ProjectDialog databases={availableDatabases} organization={organization} isEmpty={true}/>
)}
@@ -0,0 +1,101 @@
import {PageParams} from "@/types/next";
import {
Page,
PageContent,
PageDescription,
PageTitle,
} from "@/features/layout/page";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import {notFound} from "next/navigation";
import {ButtonDeleteAgent} from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
import {capitalizeFirstLetter} from "@/utils/text";
import {generateEdgeKey} from "@/utils/edge_key";
import {getServerUrl} from "@/utils/get-server-url";
import {AgentContentPage} from "@/components/wrappers/dashboard/agent/agent-content";
import {AgentDialog} from "@/features/agents/components/agent.dialog";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {currentUser} from "@/lib/auth/current-user";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
export default async function RoutePage(
props: PageParams<{ agentId: string }>,
) {
const {agentId} = await props.params;
const organization = await getOrganization({});
const user = await currentUser();
const activeMember = await getActiveMember();
if (!organization || !activeMember || !user) {
notFound();
}
const {canManageAgents} = computeOrganizationPermissions(activeMember);
if (!canManageAgents){
notFound();
}
const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId),
with: {
databases: true,
organizations: true,
},
});
if (!agent) {
notFound();
}
const hasAccess =
agent.organizationId === organization.id ||
agent.organizations.some(org => org.organizationId === organization.id);
if (!hasAccess) {
notFound();
}
const isOwned = agent.organizationId
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(agent.name)}
</div>
{isOwned && (
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<AgentDialog
agent={agent}
typeTrigger={"edit"}
/>
</div>
<div className="flex items-center gap-2">
<ButtonDeleteAgent organizationId={organization.id ?? null} agentId={agentId} text={"Delete Agent"}/>
</div>
</div>
)}
</PageTitle>
</div>
{agent.description && (
<PageDescription className="mt-5 sm:mt-0">
{agent.description}
</PageDescription>
)}
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
<AgentContentPage agent={agent} edgeKey={edgeKey}/>
</PageContent>
</Page>
);
}
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default async function RoutePage() {
redirect("/dashboard/settings?tab=agents");
}
@@ -1,95 +1,114 @@
import { PageParams } from "@/types/next";
import {PageParams} from "@/types/next";
import {
Page,
PageActions,
PageContent,
PageHeader,
PageTitle,
Page,
PageContent,
PageHeader,
PageTitle,
} from "@/features/layout/page";
import { currentUser } from "@/lib/auth/current-user";
import { getActiveMember, getOrganization } from "@/lib/auth/auth";
import { notFound } from "next/navigation";
import { Metadata } from "next";
import { OrganizationTabs } from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
import { getOrganizationChannels } from "@/db/services/notification-channel";
import { computeOrganizationPermissions } from "@/lib/acl/organization-acl";
import { getOrganizationStorageChannels } from "@/db/services/storage-channel";
import { DeleteOrganizationButton } from "@/components/wrappers/dashboard/organization/delete-organization-button";
import { EditOrganizationDialog } from "@/features/organization/components/edit-organization.dialog";
import { db } from "@/db";
import { isNull } from "drizzle-orm";
import {currentUser} from "@/lib/auth/current-user";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {notFound} from "next/navigation";
import {Metadata} from "next";
import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
import {DeleteOrganizationButton} from "@/components/wrappers/dashboard/organization/delete-organization-button";
import {EditOrganizationDialog} from "@/features/organization/components/edit-organization.dialog";
import {db} from "@/db";
import {isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import { eq } from "drizzle-orm";
import {eq} from "drizzle-orm";
import {getOrganizationAgents} from "@/db/services/agent";
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip";
export const metadata: Metadata = {
title: "Settings",
title: "Settings",
};
export default async function RoutePage(props: PageParams<{ slug: string }>) {
const organization = await getOrganization({});
const user = await currentUser();
const activeMember = await getActiveMember();
const organization = await getOrganization({});
const user = await currentUser();
const activeMember = await getActiveMember();
if (!organization || !activeMember || !user) {
notFound();
}
if (!organization || !activeMember || !user) {
notFound();
}
const notificationChannels = await getOrganizationChannels(organization.id);
const storageChannels = await getOrganizationStorageChannels(organization.id);
const permissions = computeOrganizationPermissions(activeMember);
const notificationChannels = await getOrganizationChannels(organization.id);
const storageChannels = await getOrganizationStorageChannels(organization.id);
const agents = await getOrganizationAgents(organization.id);
const permissions = computeOrganizationPermissions(activeMember);
const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt),
});
const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt),
});
const organizationWithMembers = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.id, organization.id),
with: {
members: {
const organizationWithMembers = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.id, organization.id),
with: {
user: true,
projects: true,
members: {
with: {
user: true,
},
},
},
},
},
});
});
if (!organizationWithMembers) notFound();
if (!organizationWithMembers) notFound();
return (
<Page>
<PageHeader>
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">Organization settings</div>
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
{permissions.canManageSettings &&
organization.slug !== "default" && (
<EditOrganizationDialog
organization={organizationWithMembers}
users={users}
currentUser={user}
/>
)}
</div>
<div className="flex items-center gap-2">
{permissions.canManageDangerZone &&
organization.slug !== "default" && (
<DeleteOrganizationButton
organizationSlug={organization.slug}
/>
)}
</div>
</div>
</PageTitle>
</PageHeader>
<PageContent>
<OrganizationTabs
activeMember={activeMember}
organization={organization}
notificationChannels={notificationChannels}
storageChannels={storageChannels}
/>
</PageContent>
</Page>
);
return (
<Page>
<PageHeader>
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">Organization settings</div>
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
{permissions.canManageSettings &&
organization.slug !== "default" && (
<EditOrganizationDialog
organization={organizationWithMembers}
users={users}
currentUser={user}
/>
)}
</div>
<div className="flex items-center gap-2">
{permissions.canManageDangerZone &&
organization.slug !== "default" && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<DeleteOrganizationButton
disabled={organizationWithMembers.projects.length > 0}
organizationSlug={organization.slug}
/>
</div>
</TooltipTrigger>
{organizationWithMembers.projects.length > 0 && (
<TooltipContent>
<p>Your organization has some projects associated with it. Please delete them before deleting the organization.</p>
</TooltipContent>
)}
</Tooltip>
)}
</div>
</div>
</PageTitle>
</PageHeader>
<PageContent>
<OrganizationTabs
activeMember={activeMember}
organization={organization}
notificationChannels={notificationChannels}
storageChannels={storageChannels}
agents={agents}
/>
</PageContent>
</Page>
);
}
+2 -2
View File
@@ -5,7 +5,7 @@ import {Building2, Database, DatabaseBackup, Folder, RefreshCcw, Server, Workflo
import {currentUser} from "@/lib/auth/current-user";
import {notFound} from "next/navigation";
import {db} from "@/db";
import {asc, inArray} from "drizzle-orm";
import {and, asc, eq, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {listOrganizations} from "@/lib/auth/auth";
import {Metadata} from "next";
@@ -26,7 +26,7 @@ export default async function RoutePage(props: PageParams<{}>) {
const agents = await db.query.agent.findMany({});
const projects = await db.query.project.findMany({
where: inArray(drizzleDb.schemas.project.organizationId, organizationIds),
where: and(inArray(drizzleDb.schemas.project.organizationId, organizationIds), eq(drizzleDb.schemas.project.isArchived, false)),
});
const projectIds = projects.map(project => project.id);
+2 -2
View File
@@ -1,5 +1,5 @@
import {NextResponse} from "next/server";
import {eq} from "drizzle-orm";
import {and, eq} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {logger} from "@/lib/logger";
@@ -12,7 +12,7 @@ export function withAgentCheck(handler: Function) {
const agentId = (await context.params).agentId;
const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId),
where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)),
});
if (!agent) {
@@ -70,10 +70,10 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
if (hasSuccessfulStorage && backup.status !== "success") {
await db
.update(drizzleDb.schemas.backup)
.set({
.set(withUpdatedAt({
status: "success",
fileSize: fileSize,
})
}))
.where(eq(drizzleDb.schemas.backup.id, backup.id));
}
}
+3 -2
View File
@@ -5,6 +5,7 @@ import {db} from "@/db";
import {and, eq} from "drizzle-orm";
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
import {logger} from "@/lib/logger";
import {withUpdatedAt} from "@/db/utils";
const log = logger.child({module: "api/agent/restore"});
@@ -34,7 +35,7 @@ export async function POST(
}
const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId)
where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)),
})
if (!agent) {
return NextResponse.json({error: "Agent not found"}, {status: 404})
@@ -62,7 +63,7 @@ export async function POST(
await db
.update(drizzleDb.schemas.restoration)
.set({status: body.status as RestorationStatus})
.set(withUpdatedAt({status: body.status as RestorationStatus}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
+3 -3
View File
@@ -183,7 +183,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
} else {
await dbClient
.update(drizzleDb.schemas.restoration)
.set({status: "failed"})
.set(withUpdatedAt({status: "failed"}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
const errorMessage = "Failed to get backup URL";
@@ -194,14 +194,14 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
log.error({error: err, name: "handleDatabases"}, "Restoration crashed unexpectedly");
await dbClient
.update(drizzleDb.schemas.restoration)
.set({status: "failed"})
.set(withUpdatedAt({status: "failed"}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
continue;
}
await dbClient
.update(drizzleDb.schemas.restoration)
.set({status: "ongoing"})
.set(withUpdatedAt({status: "ongoing"}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
}
const storages = await getDatabaseStorageChannels(databaseUpdated.id)
+2 -2
View File
@@ -3,7 +3,7 @@ import {handleDatabases} from "./helpers";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {EDbmsSchema} from "@/db/schema/types";
import {eq} from "drizzle-orm";
import {and, eq} from "drizzle-orm";
import {isUuidv4} from "@/utils/verify-uuid";
import {withUpdatedAt} from "@/db/utils";
import {logger} from "@/lib/logger";
@@ -46,7 +46,7 @@ export async function POST(
}
const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId),
where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)),
})
if (!agent) {
+22 -21
View File
@@ -1,6 +1,6 @@
{
"name": "portabase",
"version": "1.11.1",
"version": "1.13.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
@@ -15,8 +15,9 @@
"release": "release-it"
},
"dependencies": {
"@better-auth/passkey": "^1.5.6",
"@better-auth/sso": "^1.5.6",
"@better-auth/core": "1.6.2",
"@better-auth/passkey": "^1.6.2",
"@better-auth/sso": "^1.6.2",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -48,45 +49,45 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@react-email/components": "^0.0.41",
"@t3-oss/env-nextjs": "^0.13.11",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-query": "^5.97.0",
"@tanstack/react-table": "^8.21.3",
"@types/nodemailer": "^6.4.23",
"@types/ws": "^8.18.1",
"@zenstackhq/runtime": "2.14.2",
"argon2": "^0.43.1",
"bcrypt": "^6.0.0",
"better-auth": "1.5.6",
"better-auth": "1.6.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dockerode": "^4.0.10",
"dotenv": "^16.6.1",
"drizzle-orm": "^0.43.1",
"drizzle-zod": "^0.7.1",
"drizzle-orm": "0.45.2",
"drizzle-zod": "0.8.3",
"embla-carousel-react": "^8.6.0",
"googleapis": "^170.1.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.553.0",
"minio": "^8.0.7",
"motion": "^12.38.0",
"next": "16.2.1",
"next": "16.2.3",
"next-safe-action": "^7.10.8",
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
"node-forge": "^1.4.0",
"nodemailer": "^7.0.13",
"nodemailer": "8.0.5",
"npm-check-updates": "^18.3.1",
"pg": "^8.20.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"prettier": "^3.8.1",
"react": "^19.2.4",
"prettier": "^3.8.2",
"react": "^19.2.5",
"react-day-picker": "9.7.0",
"react-dom": "^19.2.4",
"react-dom": "^19.2.5",
"react-dropzone": "^14.4.1",
"react-email": "^4.3.2",
"react-hook-form": "^7.72.0",
"react-hook-form": "^7.72.1",
"react-qr-code": "^2.0.18",
"react-resizable-panels": "^3.0.6",
"react-twc": "^1.5.1",
@@ -101,33 +102,33 @@
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"ws": "^8.20.0",
"zod": "^3.25.76"
"zod": "4.3.6"
},
"devDependencies": {
"@iconify/react": "^6.0.2",
"@playwright/test": "1.58.2",
"@react-email/preview-server": "4.3.2",
"@react-email/render": "^2.0.4",
"@react-email/render": "^2.0.6",
"@release-it/bumper": "^7.0.5",
"@release-it/conventional-changelog": "^10.0.6",
"@tailwindcss/postcss": "^4.2.2",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.19.15",
"@types/node": "^22.19.17",
"@types/node-forge": "^1.3.14",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@zenstackhq/openapi": "^2.22.1",
"@zenstackhq/tanstack-query": "^2.22.2",
"baseline-browser-mapping": "^2.10.12",
"baseline-browser-mapping": "^2.10.17",
"drizzle-kit": "^0.31.10",
"esbuild": "^0.27.4",
"esbuild": "^0.27.7",
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.1",
"eslint-config-next": "^16.2.3",
"eslint-plugin-tailwindcss": "^3.18.2",
"framer-motion": "^12.34.3",
"framer-motion": "^12.38.0",
"node-pty": "^1.1.0",
"postcss": "^8.5.6",
"postcss": "^8.5.9",
"release-it": "^19.2.4",
"tailwindcss": "^4.2.2",
"tsx": "^4.21.0",
+1742 -1880
View File
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,7 @@ import z from "zod";
import { db } from "@/db";
import {action} from "@/lib/safe-actions/actions";
//todo: to be continued...
//TODO: to be continued...
export const forgotPasswordAction = action
.schema(
z.object({
@@ -44,7 +44,7 @@ export function BreadCrumbsWrapper() {
)
}
const FORBIDDEN_LINKS = ["organization", "dashboard", "database", "admin", "settings", "notifications", "storages"];
const FORBIDDEN_LINKS = ["organization", "dashboard", "database", "admin", "notifications", "storages"];
export function BreadCrumbs({}: BreadCrumbsProps) {
@@ -4,6 +4,7 @@ import React, { ComponentType, useState } from "react";
import { cn } from "@/lib/utils";
import { PaginationNavigation } from "@/components/wrappers/common/pagination/pagination-navigation";
import { PaginationSize } from "@/components/wrappers/common/pagination/pagination-size";
interface CardsWithPaginationProps<T> {
className?: string;
@@ -13,19 +14,21 @@ interface CardsWithPaginationProps<T> {
cardsPerPage?: number;
numberOfColumns?: number;
maxVisiblePages?: number;
pageSizeOptions?: number[];
[key: string]: any;
}
export function CardsWithPagination<T>(props: CardsWithPaginationProps<T>) {
const { className, organizationSlug, data, cardItem, cardsPerPage = 5, numberOfColumns = 1, maxVisiblePages = 3, ...rest } = props;
const { className, organizationSlug, data, cardItem, cardsPerPage = 5, numberOfColumns = 1, maxVisiblePages = 3, pageSizeOptions, ...rest } = props;
const CardItem = cardItem;
const [pageSize, setPageSize] = useState(cardsPerPage);
const [currentPage, setCurrentPage] = useState(1);
const totalPages = Math.ceil(data.length / cardsPerPage);
const totalPages = Math.ceil(data.length / pageSize);
const indexOfLastCard = currentPage * cardsPerPage;
const indexOfFirstCard = indexOfLastCard - cardsPerPage;
const indexOfLastCard = currentPage * pageSize;
const indexOfFirstCard = indexOfLastCard - pageSize;
const currentCards = data.slice(indexOfFirstCard, indexOfLastCard);
const goToPage = (pageNumber: number) => {
@@ -40,6 +43,14 @@ export function CardsWithPagination<T>(props: CardsWithPaginationProps<T>) {
goToPage(Math.min(totalPages, currentPage + 1));
};
const handlePageSizeChange = (newSize: number) => {
if (!Number.isFinite(newSize) || newSize < 1) return;
setPageSize(newSize);
setCurrentPage(1);
};
const showSizeSelector = pageSizeOptions && pageSizeOptions.length > 0;
return (
<div className={cn("flex flex-col h-full justify-between", className)}>
<div className={cn(`grid h-max auto-rows-min gap-4 md:grid-cols-${numberOfColumns}`)}>
@@ -47,15 +58,24 @@ export function CardsWithPagination<T>(props: CardsWithPaginationProps<T>) {
<CardItem key={key} data={card} organizationSlug={organizationSlug} {...rest} />
))}
</div>
<PaginationNavigation
className="justify-end mt-4"
totalPages={totalPages}
currentPage={currentPage}
goToPage={goToPage}
goToPrevPage={goToPrevPage}
goToNextPage={goToNextPage}
maxVisiblePages={maxVisiblePages}
/>
<div className="flex items-center justify-end mt-4 gap-4">
{showSizeSelector && (
<PaginationSize
pageSize={pageSize}
onPageSizeChange={handlePageSizeChange}
pageSizeOptions={pageSizeOptions}
/>
)}
<PaginationNavigation
className="justify-end"
totalPages={totalPages}
currentPage={currentPage}
goToPage={goToPage}
goToPrevPage={goToPrevPage}
goToNextPage={goToNextPage}
maxVisiblePages={maxVisiblePages}
/>
</div>
</div>
);
}
@@ -1,6 +1,6 @@
import Link from "next/link";
import {cn} from "@/lib/utils";
import {Plus} from "lucide-react";
import {CircleSlash2, Plus} from "lucide-react";
import {forwardRef, HTMLAttributes} from "react";
type EmptyStatePlaceholderProps = {
@@ -8,25 +8,32 @@ type EmptyStatePlaceholderProps = {
onClick?: () => void;
text: string;
className?: string;
state?: string
} & HTMLAttributes<HTMLDivElement>;
export const EmptyStatePlaceholder = forwardRef<HTMLDivElement, EmptyStatePlaceholderProps>(({
url,
onClick,
text,
className,
...props
}, ref) => {
url,
onClick,
text,
state,
className,
...props
}, ref) => {
const Container = (
<div
className={cn(
"flex h-full flex-col items-center justify-center w-full rounded-2xl border border-dashed border-muted p-6 lg:p-10",
"hover:bg-muted/50 transition-colors text-muted-foreground hover:text-primary text-center space-y-4",
"transition-colors text-muted-foreground text-center space-y-4",
state != "empty" && "hover:bg-muted/50 hover:text-primary",
(onClick || url) && "cursor-pointer"
)}
onClick={onClick}
>
<Plus className="w-5 h-5 lg:w-6 lg:h-6" />
{state == "empty" ?
<CircleSlash2 className="w-5 h-5 lg:w-6 lg:h-6"/>
:
<Plus className="w-5 h-5 lg:w-6 lg:h-6"/>
}
<p className="text-sm">{text}</p>
</div>
);
@@ -0,0 +1,35 @@
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
export type PaginationSizeProps = {
className?: string;
pageSize: number;
onPageSizeChange: (size: number) => void;
pageSizeOptions?: number[];
};
export const PaginationSize = (props: PaginationSizeProps) => {
const { className, onPageSizeChange, pageSizeOptions = [10, 20, 30, 40, 50] } = props;
const effectivePageSize = pageSizeOptions.includes(props.pageSize) ? props.pageSize : pageSizeOptions[0];
return (
<div className={cn("flex items-center justify-end sm:justify-center space-x-2", className)}>
<p className="whitespace-nowrap text-sm font-medium hidden md:block">Cards per page</p>
<Select
value={`${effectivePageSize}`}
onValueChange={(value) => onPageSizeChange(Number(value))}
>
<SelectTrigger className="h-8 w-[4.5rem]" aria-label="Cards per page">
<SelectValue placeholder={effectivePageSize} />
</SelectTrigger>
<SelectContent side="top">
{pageSizeOptions.map((size) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
@@ -1,7 +1,7 @@
import {z} from "zod";
export const ChannelsOrganizationSchema = z.object({
organizations: z.array(z.string())
organizations: z.array(z.string().uuid())
});
export type ChannelsOrganizationType = z.infer<typeof ChannelsOrganizationSchema>;
@@ -5,6 +5,7 @@ import { eq } from "drizzle-orm";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import {userAction} from "@/lib/safe-actions/actions";
import {withUpdatedAt} from "@/db/utils";
export const updateEmailSettingsAction = userAction
.schema(
@@ -18,9 +19,9 @@ export const updateEmailSettingsAction = userAction
const [updatedSettings] = await db
.update(drizzleDb.schemas.setting)
.set({
.set(withUpdatedAt({
...data,
})
}))
.where(eq(drizzleDb.schemas.setting.name, name))
.returning();
@@ -10,6 +10,7 @@ import {z} from "zod";
import {
DefaultNotificationSchema
} from "@/components/wrappers/dashboard/admin/settings/notification/settings-notification.schema";
import {withUpdatedAt} from "@/db/utils";
export const updateNotificationSettingsAction = userAction
.schema(
@@ -24,9 +25,9 @@ export const updateNotificationSettingsAction = userAction
try {
const [updatedSettings] = await db
.update(drizzleDb.schemas.setting)
.set({
.set(withUpdatedAt({
defaultNotificationChannelId: data.notificationChannelId ?? null,
})
}))
.where(eq(drizzleDb.schemas.setting.name, name))
.returning();
return {
@@ -8,6 +8,7 @@ import {ServerActionResult} from "@/types/action-type";
import {Setting} from "@/db/schema/01_setting";
import {z} from "zod";
import {DefaultStorageSchema} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.schema";
import {withUpdatedAt} from "@/db/utils";
export const updateStorageSettingsAction = userAction
.schema(
@@ -22,10 +23,10 @@ export const updateStorageSettingsAction = userAction
try {
const [updatedSettings] = await db
.update(drizzleDb.schemas.setting)
.set({
.set(withUpdatedAt({
defaultStorageChannelId: data.storageChannelId,
encryption: data.encryption,
})
}))
.where(eq(drizzleDb.schemas.setting.name, name))
.returning();
return {
@@ -6,6 +6,7 @@ import { S3FormSchema, StorageSwitchSchema } from "@/components/wrappers/dashboa
import { eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {userAction} from "@/lib/safe-actions/actions";
import {withUpdatedAt} from "@/db/utils";
export const updateS3SettingsAction = userAction
.schema(
@@ -19,7 +20,7 @@ export const updateS3SettingsAction = userAction
const [updatedSettings] = await db
.update(drizzleDb.schemas.setting)
.set({ ...data })
.set(withUpdatedAt({ ...data }))
.where(eq(drizzleDb.schemas.setting.name, name))
.returning();
@@ -40,6 +41,7 @@ export const updateStorageSettingsAction = userAction
const [updatedSettings] = await db
.update(drizzleDb.schemas.setting)
// @ts-ignore
.set({ ...data })
.where(eq(drizzleDb.schemas.setting.name, name))
.returning();
@@ -15,6 +15,7 @@ import {useAgentUpdateCheck} from "@/features/agents/hooks/use-agent-update-chec
export type agentCardProps = {
data: AgentWith;
organizationView?: boolean;
};
export const AgentCard = (props: agentCardProps) => {
@@ -34,7 +35,7 @@ export const AgentCard = (props: agentCardProps) => {
return (
<Link
href={`/dashboard/agents/${agent.id}`}
href={props.organizationView ? `/dashboard/settings/agents/${agent.id}`: `/dashboard/agents/${agent.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"
>
<Card className="flex flex-row items-center p-4 gap-5 transition-all border-border/50 bg-card hover:bg-accent/50 hover:border-primary/50 group-hover:shadow-md overflow-hidden">
@@ -9,8 +9,10 @@ import {deleteAgentAction} from "@/components/wrappers/dashboard/agent/button-de
import {useIsMobile} from "@/hooks/use-mobile";
export type ButtonDeleteAgentProps = {
text?: string;
agentId: string;
text?: string,
agentId: string,
organizationId?: string
organizationIds?: string[]
};
export const ButtonDeleteAgent = (props: ButtonDeleteAgentProps) => {
@@ -18,11 +20,11 @@ export const ButtonDeleteAgent = (props: ButtonDeleteAgentProps) => {
const isMobile = useIsMobile();
const mutation = useMutation({
mutationFn: () => deleteAgentAction(props.agentId),
mutationFn: () => deleteAgentAction({agentId: props.agentId, organizationId: props.organizationId, organizationIds: props.organizationIds}),
onSuccess: async (result: any) => {
if (result.data?.success) {
toast.success(result.data.actionSuccess.message);
router.push("/dashboard/agents");
router.push(props.organizationId ? "/dashboard/settings?tab=agents" : "/dashboard/agents");
} else {
toast.error(result.data.actionError.message || "Unknown error occurred.");
}
@@ -35,7 +37,7 @@ export const ButtonDeleteAgent = (props: ButtonDeleteAgentProps) => {
description="Are you sure you want to remove this agent? This action cannot be undone."
button={{
main: {
text: props.text ? !isMobile ? props.text: "" : "",
text: props.text ? !isMobile ? props.text : "" : "",
variant: "outline",
icon: <Trash2 color="red"/>,
},
@@ -3,51 +3,135 @@
import {z} from "zod";
import {v4 as uuidv4} from "uuid";
import {ServerActionResult} from "@/types/action-type";
import {eq} from "drizzle-orm";
import {and, eq, inArray} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {Agent} from "@/db/schema/08_agent";
import {userAction} from "@/lib/safe-actions/actions";
import {zString} from "@/lib/zod";
import {withUpdatedAt} from "@/db/utils";
export const deleteAgentAction = userAction.schema(z.string()).action(async ({parsedInput}): Promise<ServerActionResult<Agent>> => {
try {
export const deleteAgentAction = userAction
.schema(
z.object({
agentId: zString(),
organizationId: zString().optional(),
organizationIds: z.array(z.string()).optional()
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Agent>> => {
const {agentId, organizationId, organizationIds} = parsedInput;
// const deletedAgent: Agent[] = await db.delete(drizzleDb.schemas.agent).where(eq(drizzleDb.schemas.agent.id, parsedInput)).returning();
try {
let projectIds: string[] = [];
const uuid = uuidv4();
const uuid = uuidv4();
if (organizationId) {
await db
.delete(drizzleDb.schemas.organizationAgent)
.where(
and(
eq(drizzleDb.schemas.organizationAgent.organizationId, organizationId),
eq(drizzleDb.schemas.organizationAgent.agentId, agentId)
)
);
const updatedAgent = await db
.update(drizzleDb.schemas.agent)
.set({
isArchived: true,
slug: uuid,
})
.where(eq(drizzleDb.schemas.agent.id, parsedInput))
.returning();
const organization = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.id, organizationId),
with: {
projects: true,
}
});
projectIds = organization?.projects?.map(project => project.id) ?? [];
if (!updatedAgent[0]) {
throw new Error("Agent not found or update failed");
} else if (organizationIds) {
const organizationsToRemoveDetails = await db.query.organization.findMany({
where: inArray(drizzleDb.schemas.organization.id, organizationIds),
with: {
projects: true
}
});
projectIds = organizationsToRemoveDetails.flatMap(org =>
org.projects.map(project => project.id)
);
}
if (projectIds?.length > 0) {
const databases = await db.query.database.findMany({
where: (db, {inArray}) => inArray(db.projectId, projectIds),
columns: {id: true}
});
const databaseIds = databases.map(d => d.id);
await db
.update(drizzleDb.schemas.database)
.set(withUpdatedAt({
backupPolicy: null,
projectId: null
}))
.where(inArray(drizzleDb.schemas.database.projectId, projectIds))
.execute();
await db.delete(drizzleDb.schemas.retentionPolicy)
.where(inArray(drizzleDb.schemas.retentionPolicy.databaseId, databaseIds))
.execute();
await db.delete(drizzleDb.schemas.alertPolicy)
.where(inArray(drizzleDb.schemas.alertPolicy.databaseId, databaseIds))
.execute();
await db.delete(drizzleDb.schemas.storagePolicy)
.where(inArray(drizzleDb.schemas.storagePolicy.databaseId, databaseIds))
.execute();
}
const updatedAgent = await db
.update(drizzleDb.schemas.agent)
.set(withUpdatedAt({
isArchived: true,
slug: uuid,
deletedAt: new Date()
}))
.where(eq(drizzleDb.schemas.agent.id, agentId))
.returning();
if (!updatedAgent[0]) {
return {
success: false,
actionError: {
message: "Agent not found or update failed",
status: 404,
messageParams: {agentId: agentId},
},
};
}
return {
success: true,
value: updatedAgent[0],
actionSuccess: {
message: "Agent has been successfully deleted.",
messageParams: {projectId: agentId},
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to delete agent.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {agentId: agentId},
},
};
}
return {
success: true,
value: updatedAgent[0],
actionSuccess: {
message: "Agent has been successfully deleted.",
messageParams: {projectId: parsedInput},
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to delete agent.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {projectId: parsedInput},
},
};
}
});
});
@@ -7,7 +7,8 @@ import {
Layers,
ChartArea,
ShieldHalf,
Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse, BookOpen
Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse, BookOpen, Hammer,
ChevronsLeftRightEllipsis
} from "lucide-react";
import {SidebarGroupItem, SidebarMenuCustomBase} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar";
import {authClient} from "@/lib/auth/auth-client";
@@ -32,7 +33,17 @@ export const SidebarMenuCustomMain = () => {
const groupContent: SidebarGroupItem["group_content"] = [
{title: "Projects", url: "/projects", icon: Layers, details: true, type: "item"},
{title: "Statistics", url: "/statistics", icon: ChartArea, type: "item"},
{title: "Settings", url: "/settings", icon: Settings, details: true, type: "item"}
{title: "Settings", url: "/settings", icon: Settings, details: true, type: "item"},
{
title: "Tools",
url: "/tools",
icon: Hammer,
details: true,
type: "collapse",
submenu: [
{title: "Migration", url: "/migration", icon: ChevronsLeftRightEllipsis, type: "item"},
]
}
];
@@ -69,8 +80,8 @@ export const SidebarMenuCustomMain = () => {
details: true,
type: "collapse",
submenu: [
{ title: "Channels", url: "/notifications/channels", icon: Blocks, type: "item" },
{ title: "Activity Logs", url: "/notifications/logs", icon: Logs, type: "item" },
{title: "Channels", url: "/notifications/channels", icon: Blocks, type: "item"},
{title: "Activity Logs", url: "/notifications/logs", icon: Logs, type: "item"},
],
},
{
@@ -80,7 +91,7 @@ export const SidebarMenuCustomMain = () => {
details: true,
type: "collapse",
submenu: [
{ title: "Channels", url: "/storages/channels", icon: Blocks, type: "item" },
{title: "Channels", url: "/storages/channels", icon: Blocks, type: "item"},
],
},
{
@@ -91,7 +102,13 @@ export const SidebarMenuCustomMain = () => {
type: "collapse",
submenu: [
{title: "Users", url: "/admin/users", icon: Users, type: "item"},
{title: "Organizations", url: "/admin/organizations", icon: Building, type: "item", details: true},
{
title: "Organizations",
url: "/admin/organizations",
icon: Building,
type: "item",
details: true
},
],
},
{
@@ -105,7 +122,7 @@ export const SidebarMenuCustomMain = () => {
}
items.push(
{
{
label: "Resources",
type: "list",
group_content: [
@@ -38,7 +38,7 @@ export const ImportModal = ({database}: ImportModalProps) => {
<Separator className="mt-3 "/>
<UploadBackupZone
onSuccessAction={() => setOpen(false)}
databaseId={database.id}/>
database={database}/>
</DialogHeader>
</DialogContent>
</Dialog>
@@ -9,13 +9,15 @@ import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with
import {toast} from "sonner";
import {uploadBackupAction} from "@/components/wrappers/dashboard/database/import/upload-backup.action";
import {Card, CardContent} from "@/components/ui/card";
import {DatabaseWith} from "@/db/schema/07_database";
import {getFileHeadersBasedOnDbms} from "@/utils/common";
type UploadRetentionZoneProps = {
onSuccessAction?: () => void;
databaseId: string;
database: DatabaseWith;
};
export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZoneProps) => {
export const UploadBackupZone = ({onSuccessAction, database}: UploadRetentionZoneProps) => {
const queryClient = useQueryClient();
const router = useRouter();
@@ -30,7 +32,7 @@ export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZ
const formData = new FormData();
formData.append("file", file);
formData.append("databaseId", databaseId);
formData.append("databaseId", database.id);
const result = await uploadBackupAction(formData)
@@ -38,7 +40,7 @@ export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZ
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
onSuccessAction?.()
queryClient.invalidateQueries({queryKey: ["database-data", databaseId]});
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
router.refresh();
} else {
toast.error(inner?.actionError?.message);
@@ -47,19 +49,18 @@ export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZ
console.error(err);
toast.error("An error occurred while upload in the backup");
} finally {
queryClient.invalidateQueries({queryKey: ["database-data", databaseId]});
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
setIsProcessing(false);
}
},
});
const acceptDbImportFiles = getFileHeadersBasedOnDbms(database.dbms)
const fileKindDescription = Object.values(acceptDbImportFiles)
.flat()
.join(", ");
const acceptDbImportFiles: Record<string, string[]> = {
"application/sql": [".sql"],
"application/x-sql": [".sql"],
"text/plain": [".sql"],
"application/octet-stream": [".dump"],
};
return (
<>
@@ -67,11 +68,11 @@ export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZ
<UploadLoader label="Uploading database backup…"/>
) : (
<DropZoneFile
accept={acceptDbImportFiles}
accept={getFileHeadersBasedOnDbms(database.dbms)}
maxSize={2 * 1024 * 1024 * 1024}
maxFiles={1}
description="Import database backup"
fileKind="Database file (.sql, .dump)"
fileKind={`Database file (${fileKindDescription})`}
dragMessage="Click or drag a database dump here"
onFileDropAction={(file: File) => setFile(file)}
/>
@@ -86,8 +87,6 @@ export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZ
</ButtonWithLoading>
</div>
)}
</>
);
};
@@ -10,6 +10,7 @@ import {useIsMobile} from "@/hooks/use-mobile";
export type DeleteOrganizationButtonProps = {
organizationSlug: string;
disabled?: boolean
};
export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) => {
@@ -51,6 +52,7 @@ export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) =
main: {
text: !isMobile ? "Delete Organization" : "",
variant: "outline",
disabled: props.disabled,
icon: <Trash2 color="red"/>,
},
confirm: {
@@ -0,0 +1,272 @@
"use client"
import {
ArrowRight,
CheckCircle2,
Circle,
Loader2,
Play,
Server,
FolderOpen,
HardDrive,
} from "lucide-react"
import {cn} from "@/lib/utils"
import {ProjectWithDatabasesAndBackups as ProjectWith} from "@/db/schema/06_project";
import {Backup, DatabaseWith} from "@/db/schema/07_database";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
interface MigrationFlowProps {
sourceProject: ProjectWith | null
sourceDatabase: DatabaseWith | null
selectedBackups: Backup[]
targetProject: ProjectWith | null
targetDatabase: DatabaseWith | null
status: MigrationStatus
onStartMigration: () => void
canStart: boolean
}
export type MigrationStatus = "idle" | "migrating" | "completed" | "error"
export function MigrationFlow({
sourceProject,
sourceDatabase,
selectedBackups,
targetProject,
targetDatabase,
status,
onStartMigration,
canStart,
}: MigrationFlowProps) {
const steps = [
{
id: "source-project",
label: "Source Project",
description: "Choose source project",
completed: sourceProject !== null,
active: status === "idle" && !sourceProject,
},
{
id: "source-database",
label: "Source Database",
description: "Select database",
completed: sourceDatabase !== null,
active: status === "idle" && sourceProject !== null && !sourceDatabase,
},
{
id: "backups",
label: "Select Backups",
description: "Choose backups to migrate",
completed: selectedBackups.length > 0,
active: status === "idle" && sourceDatabase !== null && selectedBackups.length === 0,
},
{
id: "target-project",
label: "Target Project",
description: "Choose destination project",
completed: targetProject !== null,
active: status === "idle" && selectedBackups.length > 0 && !targetProject,
},
{
id: "target-database",
label: "Target Database",
description: "Select destination database",
completed: targetDatabase !== null,
active: status === "idle" && targetProject !== null && !targetDatabase,
},
{
id: "migrate",
label: "Migrate Data",
description: "Transfer to target",
completed: status === "completed",
active: status === "migrating",
},
]
return (
<div className="flex h-full flex-col rounded-xl border border-border bg-card">
<div className="border-b border-border px-5 py-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<ArrowRight className="h-5 w-5 text-primary"/>
</div>
<div>
<h2 className="font-semibold text-foreground">Migration Flow</h2>
<p className="text-sm text-muted-foreground">
{status === "idle" && "Configure your migration"}
{status === "migrating" && "Migration in progress..."}
{status === "completed" && "Migration completed!"}
</p>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-5">
<div className="mb-6 flex flex-col gap-3">
{steps.map((step, index) => (
<div key={step.id} className="flex items-start gap-3">
<div className="flex flex-col items-center">
<div
className={cn(
"flex h-7 w-7 items-center justify-center rounded-full border-2 transition-all",
step.completed
? "border-emerald-500 bg-emerald-500 text-white"
: step.active
? "border-primary bg-primary/10 text-primary"
: "border-muted-foreground/30 bg-muted text-muted-foreground"
)}
>
{step.completed ? (
<CheckCircle2 className="h-4 w-4"/>
) : step.active && status === "migrating" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin"/>
) : (
<Circle className="h-3.5 w-3.5"/>
)}
</div>
{index < steps.length - 1 && (
<div
className={cn(
"mt-1 h-6 w-0.5",
step.completed ? "bg-emerald-500" : "bg-border"
)}
/>
)}
</div>
<div className="pt-0.5">
<p
className={cn(
"text-sm font-medium",
step.completed || step.active
? "text-foreground"
: "text-muted-foreground"
)}
>
{step.label}
</p>
<p className="text-xs text-muted-foreground">{step.description}</p>
</div>
</div>
))}
</div>
{(sourceProject || targetProject) && (
<div className="mb-6 rounded-lg border border-border bg-muted/30 p-4">
<h3 className="mb-3 text-sm font-medium text-foreground">
Migration Summary
</h3>
<div className="flex flex-col gap-2.5 text-sm">
{/* Source */}
{sourceProject && (
<div className="flex items-start gap-2">
<FolderOpen className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground"/>
<div className="min-w-0">
<span className="text-muted-foreground">From: </span>
<span className="font-medium text-foreground">
{sourceProject.name}
</span>
{sourceDatabase && (
<span className="text-muted-foreground">
{" "}/ {sourceDatabase.name}
</span>
)}
</div>
</div>
)}
{selectedBackups.length > 0 && (
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground"/>
<span className="text-muted-foreground">Backups:</span>
<span className="font-medium text-foreground">
{selectedBackups.length}
</span>
</div>
)}
{targetProject && (
<div className="flex items-start gap-2">
<Server className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground"/>
<div className="min-w-0">
<span className="text-muted-foreground">To: </span>
<span className="font-medium text-foreground">
{targetProject.name}
</span>
{targetDatabase && (
<span className="text-muted-foreground">
{" "}/ {targetDatabase.name}
</span>
)}
</div>
</div>
)}
</div>
</div>
)}
{status === "completed" && (
<div className="mb-6 rounded-lg border border-emerald-500/20 bg-emerald-500/10 p-4">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-emerald-600"/>
<span className="font-medium text-emerald-600">
Migration completed successfully!
</span>
</div>
</div>
)}
</div>
<div className="border-t border-border p-5">
<ButtonWithLoading
className={cn(
"flex w-full items-center justify-center gap-2 rounded-lg px-4 py-3 font-medium transition-all",
canStart
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "cursor-not-allowed bg-muted text-muted-foreground"
)}
onClick={onStartMigration}
disabled={!canStart}
isPending={status === "migrating"}
size="lg"
type="button"
>
{status === "migrating" ? (
<>
Migrating...
</>
) : status === "completed" ? (
<>
<CheckCircle2 className="h-4 w-4"/>
Completed
</>
) : (
<>
<Play className="h-4 w-4"/>
Start Migration
</>
)}
</ButtonWithLoading>
{!canStart && status === "idle" && (
<p className="mt-2 text-center text-xs text-muted-foreground">
{!sourceProject
? "Select a source project"
: !sourceDatabase
? "Select a source database"
: selectedBackups.length === 0
? "Select at least one backup"
: !targetProject
? "Select a target project"
: !targetDatabase
? "Select a target database"
: "Ready to start"}
</p>
)}
</div>
</div>
)
}
@@ -0,0 +1,172 @@
"use client"
import {useState, useMemo} from "react"
import {ProjectWithDatabasesAndBackups as ProjectWith} from "@/db/schema/06_project"
import {SourcePanel} from "@/components/wrappers/dashboard/organization/migration/source-panel"
import {Backup, DatabaseWith} from "@/db/schema/07_database"
import {MigrationFlow, MigrationStatus} from "@/components/wrappers/dashboard/organization/migration/migration-flow"
import {TargetPanel} from "@/components/wrappers/dashboard/organization/migration/target-panel"
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {migrationAction} from "@/components/wrappers/dashboard/organization/migration/migration.action";
import {useRouter} from "next/navigation";
interface MigrationToolProps {
projects: ProjectWith[]
}
export const MigrationTool = ({projects}: MigrationToolProps) => {
const [sourceProject, setSourceProject] = useState<ProjectWith | null>(null)
const [sourceDatabase, setSourceDatabase] = useState<DatabaseWith | null>(null)
const [selectedBackups, setSelectedBackups] = useState<Backup[]>([])
const router = useRouter()
const [targetProject, setTargetProject] = useState<ProjectWith | null>(null)
const [targetDatabase, setTargetDatabase] = useState<DatabaseWith | null>(null)
const [migrationStatus, setMigrationStatus] = useState<MigrationStatus>("idle")
const [_migrationProgress, setMigrationProgress] = useState(0)
const sourceDbKind = sourceDatabase?.dbms
const isTargetEnabled = useMemo(() => {
return migrationStatus === "idle" &&
sourceDatabase !== null &&
selectedBackups.length > 0
}, [sourceDatabase, migrationStatus, selectedBackups])
const filteredProjects = useMemo(() => {
if (!sourceDbKind) return []
return projects.map((project) => ({
...project,
databases: project.databases.filter((db) => {
const sameKind = db.dbms === sourceDbKind
const notSource = db.id !== sourceDatabase?.id
return sameKind && notSource
}),
}))
}, [projects, sourceDbKind, sourceDatabase])
const handleSelectSourceProject = (project: ProjectWith | null) => {
setSourceProject(project)
setSourceDatabase(null)
setSelectedBackups([])
setTargetProject(null)
setTargetDatabase(null)
}
const handleSelectSourceDatabase = (database: DatabaseWith | null) => {
setSourceDatabase(database)
setSelectedBackups([])
if (targetDatabase && database?.dbms !== targetDatabase.dbms) {
setTargetProject(null)
setTargetDatabase(null)
}
if (targetDatabase && database?.id === targetDatabase.id) {
setTargetProject(null)
setTargetDatabase(null)
}
}
const handleSelectBackup = (backup: Backup) => {
setSelectedBackups((prev) => {
const isSelected = prev.some((b) => b.id === backup.id)
if (isSelected) {
return prev.filter((b) => b.id !== backup.id)
}
return [...prev, backup]
})
}
const handleSelectTargetProject = (project: ProjectWith | null) => {
setTargetProject(project)
setTargetDatabase(null)
}
const handleSelectTargetDatabase = (database: DatabaseWith | null) => {
setTargetDatabase(database)
}
const mutation = useMutation({
mutationFn: async () => {
if (selectedBackups.length === 0 || !targetDatabase) return
setMigrationStatus("migrating")
const result = await migrationAction({
targetDatabaseId: targetDatabase?.id,
backupIds: selectedBackups.map((backup) => backup.id)
})
const inner = result?.data;
console.log(inner)
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
handleReset()
router.refresh();
} else {
toast.error(inner?.actionError?.message);
}
},
});
const handleReset = () => {
setMigrationStatus("idle")
setMigrationProgress(0)
setSelectedBackups([])
setSourceProject(null)
setSourceDatabase(null)
setTargetProject(null)
setTargetDatabase(null)
}
const canStartMigration =
selectedBackups.length > 0 &&
targetDatabase !== null &&
migrationStatus === "idle"
return (
<div className="h-full">
<div className="flex flex-col md:grid md:grid-cols-12 gap-6 h-full">
<div className="col-span-4">
<SourcePanel
projects={projects}
selectedProject={sourceProject}
selectedDatabase={sourceDatabase}
selectedBackups={selectedBackups}
onSelectProject={handleSelectSourceProject}
onSelectDatabase={handleSelectSourceDatabase}
onSelectBackup={handleSelectBackup}
disabled={migrationStatus !== "idle"}
/>
</div>
<div className="col-span-4">
<MigrationFlow
sourceProject={sourceProject}
sourceDatabase={sourceDatabase}
selectedBackups={selectedBackups}
targetProject={targetProject}
targetDatabase={targetDatabase}
status={migrationStatus}
onStartMigration={() => mutation.mutateAsync()}
canStart={canStartMigration}
/>
</div>
<div className="col-span-4 min-h-36">
<TargetPanel
projects={filteredProjects}
selectedProject={targetProject}
selectedDatabase={targetDatabase}
onSelectProject={handleSelectTargetProject}
onSelectDatabase={handleSelectTargetDatabase}
disabled={!isTargetEnabled}
/>
</div>
</div>
</div>
)
}
@@ -0,0 +1,192 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {ServerActionResult} from "@/types/action-type";
import {z} from "zod";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq, inArray} from "drizzle-orm";
import {dispatchStorage} from "@/features/storages/dispatch";
import {v4 as uuidv4} from "uuid";
import {getTodayISODate} from "@/utils/date-formatting";
import {withUpdatedAt} from "@/db/utils";
export const migrationAction = userAction.schema(
z.object({
targetDatabaseId: z.string(),
backupIds: z.array(z.string()),
})
).action(async ({ parsedInput }): Promise<ServerActionResult<{}>> => {
const { targetDatabaseId, backupIds } = parsedInput;
let hasError = false;
try {
const targetDatabase = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, targetDatabaseId),
with: {
project: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true
}
});
if (!targetDatabase) {
return {
success: false,
actionError: {
message: "Unable to find target database",
status: 404,
},
};
}
const backups = await db.query.backup.findMany({
where: inArray(drizzleDb.schemas.backup.id, backupIds),
with: {
database: true,
storages: true
}
});
if (backups.length !== backupIds.length) {
return {
success: false,
actionError: {
message: "Some backups were not found",
status: 400,
},
};
}
await Promise.all(
backups.map(async (backup) => {
const [migratedBackup] = await db
.insert(drizzleDb.schemas.backup)
.values({
status: "ongoing",
databaseId: targetDatabaseId,
fileSize: backup.fileSize,
migrated: true,
})
.returning();
const storageResults = await Promise.allSettled(
backup.storages.map(async (storage) => {
const [backupStorage] = await db
.insert(drizzleDb.schemas.backupStorage)
.values({
backupId: migratedBackup.id,
storageChannelId: storage.storageChannelId,
status: "pending",
})
.returning();
const fileName = `${uuidv4()}.tar.gz`;
const pathTo = `backups/${getTodayISODate()}/${fileName}`;
try {
const result = await dispatchStorage(
{
action: "copy",
data: {
from: storage.path ?? "",
to: pathTo,
},
metadata: {
storageId: storage.storageChannelId,
fileKind: "backups",
},
},
undefined,
storage.storageChannelId
);
if (!result.success) {
hasError = true;
throw new Error("dispatchStorage failed");
}
await db
.update(drizzleDb.schemas.backupStorage)
.set(
withUpdatedAt({
status: "success",
path: pathTo,
size: backup.fileSize,
})
)
.where(eq(drizzleDb.schemas.backupStorage.id, backupStorage.id));
return { success: true };
} catch (error) {
hasError = true;
await db
.update(drizzleDb.schemas.backupStorage)
.set(
withUpdatedAt({
status: "failed",
})
)
.where(eq(drizzleDb.schemas.backupStorage.id, backupStorage.id));
return { success: false };
}
})
);
const failed = storageResults.some(
r => r.status === "rejected" || (r.status === "fulfilled" && !r.value.success)
);
if (failed) hasError = true;
await db
.update(drizzleDb.schemas.backup)
.set(
withUpdatedAt({
status: failed ? "failed" : "success",
})
)
.where(eq(drizzleDb.schemas.backup.id, migratedBackup.id));
})
);
if (hasError) {
return {
success: false,
actionError: {
message: "Migration completed with errors",
status: 500,
},
};
}
return {
success: true,
value: {},
actionSuccess: {
message: "Backups successfully migrated.",
messageParams: {},
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Migration failed unexpectedly",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -0,0 +1,309 @@
"use client"
import {
Database,
HardDrive,
Calendar,
Check,
ChevronLeft,
FolderOpen,
Server,
} from "lucide-react"
import {cn} from "@/lib/utils"
import {ProjectWithDatabasesAndBackups as ProjectWith} from "@/db/schema/06_project";
import {Backup, DatabaseWith} from "@/db/schema/07_database";
import {ScrollArea} from "@/components/ui/scroll-area";
import {formatLocalizedDate} from "@/utils/date-formatting";
import {formatBytes, truncateWords} from "@/utils/text";
type ViewState = "projects" | "databases" | "backups"
interface SourcePanelProps {
projects: ProjectWith[]
selectedProject: ProjectWith | null
selectedDatabase: DatabaseWith | null
selectedBackups: Backup[]
onSelectProject: (project: ProjectWith | null) => void
onSelectDatabase: (database: DatabaseWith | null) => void
onSelectBackup: (backup: Backup) => void
disabled?: boolean
}
export function SourcePanel({
projects,
selectedProject,
selectedDatabase,
selectedBackups,
onSelectProject,
onSelectDatabase,
onSelectBackup,
disabled,
}: SourcePanelProps) {
const currentView: ViewState = selectedDatabase
? "backups"
: selectedProject
? "databases"
: "projects"
const handleBack = () => {
if (currentView === "backups") {
onSelectDatabase(null)
} else if (currentView === "databases") {
onSelectProject(null)
}
}
const getHeaderInfo = () => {
if (currentView === "backups" && selectedDatabase) {
return {
title: selectedDatabase.name,
subtitle: selectedDatabase.backups?.length? `${selectedBackups.length} of ${selectedDatabase.backups.length} backups selected` : "",
icon: <Database className="h-5 w-5 text-primary"/>,
}
}
if (currentView === "databases" && selectedProject) {
return {
title: selectedProject.name,
subtitle: `${selectedProject.databases.length} database${selectedProject.databases.length !== 1 ? "s" : ""} available`,
icon: <FolderOpen className="h-5 w-5 text-primary"/>,
}
}
return {
title: "Source",
subtitle: "Select a project to start",
icon: <FolderOpen className="h-5 w-5 text-primary"/>,
}
}
const headerInfo = getHeaderInfo()
return (
<div className="rounded-xl border border-border bg-card h-full flex flex-col overflow-hidden">
<div className="border-b border-border px-5 py-4">
<div className="flex items-center gap-3">
{currentView !== "projects" ? (
<button
onClick={handleBack}
disabled={disabled}
className="flex h-9 w-9 items-center justify-center rounded-lg bg-secondary transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronLeft className="h-5 w-5 text-secondary-foreground"/>
</button>
) : (
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
{headerInfo.icon}
</div>
)}
<div className="min-w-0 flex-1">
<h2 className="truncate font-semibold text-foreground">
{headerInfo.title}
</h2>
<p className="text-sm text-muted-foreground">{headerInfo.subtitle}</p>
</div>
</div>
{currentView !== "projects" && (
<div className="mt-3 flex items-center gap-1.5 text-xs text-muted-foreground">
<button
onClick={() => {
onSelectDatabase(null)
onSelectProject(null)
}}
disabled={disabled}
className="hover:text-foreground disabled:opacity-50"
>
Projects
</button>
{selectedProject && (
<>
<span>/</span>
<button
onClick={() => onSelectDatabase(null)}
disabled={disabled}
className={cn(
"truncate hover:text-foreground disabled:opacity-50",
currentView === "databases" && "text-foreground"
)}
>
{selectedProject.name}
</button>
</>
)}
{selectedDatabase && (
<>
<span>/</span>
<span className="truncate text-foreground">
{selectedDatabase.name}
</span>
</>
)}
</div>
)}
</div>
<ScrollArea
// Do not remove the radix scroll
className="w-full flex-1 [&>[data-radix-scroll-area-viewport]]:max-h-[calc(100vh-320px)]"
>
<div className="p-4">
{currentView === "projects" && (
<div className="flex flex-col gap-2">
{projects.length === 0 ? (
<div
className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border p-6 text-center">
<p className="text-sm text-muted-foreground">
No projects available
</p>
</div>
) : (
projects.map((project) => (
<button
key={project.id}
onClick={() => onSelectProject(project)}
disabled={disabled}
className={cn(
"group w-full rounded-lg border border-border bg-card p-4 text-left transition-all",
"hover:border-primary/50 hover:bg-accent/50",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
disabled && "cursor-not-allowed opacity-50"
)}
>
<div className="flex items-start gap-3">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<Server className="h-5 w-5 text-muted-foreground"/>
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate font-medium text-foreground">
{truncateWords(project.name, 6)}
</h3>
<p className="mt-2 text-sm text-muted-foreground">
{project.databases.length} database
{project.databases.length !== 1 ? "s" : ""}
</p>
</div>
<ChevronLeft
className="h-5 w-5 rotate-180 text-muted-foreground transition-transform group-hover:translate-x-1"/>
</div>
</button>
))
)}
</div>
)}
{currentView === "databases" && selectedProject && (
<div className="flex flex-col gap-2">
{selectedProject.databases.length === 0 ? (
<div className="py-8 text-center">
<Database className="mx-auto h-10 w-10 text-muted-foreground/50"/>
<p className="mt-2 text-sm text-muted-foreground">
No databases in this project
</p>
</div>
) : (
selectedProject.databases.map((database) => (
<button
key={database.id}
onClick={() => onSelectDatabase(database)}
disabled={disabled}
className={cn(
"group w-full rounded-lg border border-border bg-card p-4 text-left transition-all",
"hover:border-primary/50 hover:bg-accent/50",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
disabled && "cursor-not-allowed opacity-50"
)}
>
<div className="flex items-start gap-3">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<Database className="h-5 w-5 text-muted-foreground"/>
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate font-medium text-foreground">
{truncateWords(database.name, 6)}
</h3>
<p className="mt-0.5 text-sm text-muted-foreground">
{database.dbms}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{database?.backups?.length} backup
{database?.backups?.length !== 1 ? "s" : ""} available
</p>
</div>
<ChevronLeft
className="h-5 w-5 rotate-180 text-muted-foreground transition-transform group-hover:translate-x-1"/>
</div>
</button>
))
)}
</div>
)}
{currentView === "backups" && selectedDatabase && (
<div className="flex flex-col gap-2">
{selectedDatabase?.backups?.length === 0 ? (
<div className="py-8 text-center">
<HardDrive className="mx-auto h-10 w-10 text-muted-foreground/50"/>
<p className="mt-2 text-sm text-muted-foreground">
No backups available for this database
</p>
</div>
) : (
selectedDatabase?.backups?.map((backup) => {
const isSelected = selectedBackups.some((b) => b.id === backup.id)
return (
<button
key={backup.id}
onClick={() => onSelectBackup(backup)}
disabled={disabled}
className={cn(
"group relative w-full rounded-lg border p-4 text-left transition-all",
"hover:border-primary/50 hover:bg-accent/50",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
isSelected
? "border-primary bg-primary/5"
: "border-border bg-card",
disabled && "cursor-not-allowed opacity-50"
)}
>
<div
className={cn(
"absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full border-2 transition-all",
isSelected
? "border-primary bg-primary"
: "border-muted-foreground/30 bg-transparent"
)}
>
{isSelected && (
<Check className="h-3 w-3 text-primary-foreground"/>
)}
</div>
<div className="mb-3 pr-8">
<h3 className="font-medium text-foreground">{backup.id}</h3>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<HardDrive className="h-3.5 w-3.5"/>
<span>{formatBytes(backup.fileSize)}</span>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Calendar className="h-3.5 w-3.5"/>
<span>{ formatLocalizedDate(backup.createdAt)}</span>
</div>
</div>
</button>
)
})
)}
</div>
)}
</div>
</ScrollArea>
</div>
)
}
@@ -0,0 +1,214 @@
"use client"
import {
Database,
Check,
ChevronLeft,
FolderOpen,
Server,
} from "lucide-react"
import {cn} from "@/lib/utils"
import {ProjectWithDatabasesAndBackups as ProjectWith} from "@/db/schema/06_project";
import {DatabaseWith} from "@/db/schema/07_database";
import {ScrollArea} from "@/components/ui/scroll-area";
import {truncateWords} from "@/utils/text";
type ViewState = "projects" | "databases"
interface TargetPanelProps {
projects: ProjectWith[]
selectedProject: ProjectWith | null
selectedDatabase: DatabaseWith | null
onSelectProject: (project: ProjectWith | null) => void
onSelectDatabase: (database: DatabaseWith | null) => void
disabled?: boolean
}
export function TargetPanel({
projects,
selectedProject,
selectedDatabase,
onSelectProject,
onSelectDatabase,
disabled,
}: TargetPanelProps) {
const currentView: ViewState = selectedProject ? "databases" : "projects"
const handleBack = () => {
onSelectDatabase(null)
onSelectProject(null)
}
const getHeaderInfo = () => {
if (selectedProject) {
return {
title: selectedProject.name,
subtitle: selectedDatabase
? selectedDatabase.name
: "Select a target database",
icon: <FolderOpen className="h-5 w-5 text-primary"/>,
}
}
return {
title: "Target",
subtitle: "Select destination project",
icon: <Server className="h-5 w-5 text-primary"/>,
}
}
const headerInfo = getHeaderInfo()
return (
<div className="rounded-xl border border-border bg-card h-full flex flex-col overflow-hidden">
<div className="border-b border-border px-5 py-4">
<div className="flex items-center gap-3">
{currentView !== "projects" ? (
<button
onClick={handleBack}
disabled={disabled}
className="flex h-9 w-9 items-center justify-center rounded-lg bg-secondary transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronLeft className="h-5 w-5 text-secondary-foreground"/>
</button>
) : (
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
{headerInfo.icon}
</div>
)}
<div className="min-w-0 flex-1">
<h2 className="truncate font-semibold text-foreground">
{headerInfo.title}
</h2>
<p className="text-sm text-muted-foreground">{headerInfo.subtitle}</p>
</div>
</div>
{currentView !== "projects" && (
<div className="mt-3 flex items-center gap-1.5 text-xs text-muted-foreground">
<button
onClick={handleBack}
disabled={disabled}
className="hover:text-foreground disabled:opacity-50"
>
Projects
</button>
{selectedProject && (
<>
<span>/</span>
<span className="truncate text-foreground">
{selectedProject.name}
</span>
</>
)}
</div>
)}
</div>
<ScrollArea
// Do not remove the radix scroll
className="w-full flex-1 [&>[data-radix-scroll-area-viewport]]:max-h-[calc(100vh-320px)]"
>
<div className="p-4">
{currentView === "projects" && (
<div className="flex flex-col gap-2">
{projects.map((project) => (
<button
key={project.id}
onClick={() => onSelectProject(project)}
disabled={disabled}
className={cn(
"group w-full rounded-lg border border-border bg-card p-4 text-left transition-all",
"hover:border-primary/50 hover:bg-accent/50",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
disabled && "cursor-not-allowed opacity-50"
)}
>
<div className="flex items-start gap-3">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<Server className="h-5 w-5 text-muted-foreground"/>
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate font-medium text-foreground">
{truncateWords(project.name, 6)}
</h3>
<div className="mt-1 flex items-center gap-2">
</div>
<p className="mt-2 text-sm text-muted-foreground">
{project.databases.length} database
{project.databases.length !== 1 ? "s" : ""}
</p>
</div>
<ChevronLeft
className="h-5 w-5 rotate-180 text-muted-foreground transition-transform group-hover:translate-x-1"/>
</div>
</button>
))}
</div>
)}
{currentView === "databases" && selectedProject && (
<div className="flex flex-col gap-2">
{selectedProject.databases.length === 0 ? (
<div className="py-8 text-center">
<Database className="mx-auto h-10 w-10 text-muted-foreground/50"/>
<p className="mt-2 text-sm text-muted-foreground">
No databases in this project
</p>
</div>
) : (
selectedProject.databases.map((database) => {
const isSelected = selectedDatabase?.id === database.id
return (
<button
key={database.id}
onClick={() => onSelectDatabase(database)}
disabled={disabled}
className={cn(
"group relative w-full rounded-lg border p-4 text-left transition-all",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
isSelected
? "border-primary bg-primary/5"
: "border-border bg-card",
(disabled) && "cursor-not-allowed opacity-50"
)}
>
<div
className={cn(
"absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full border-2 transition-all",
isSelected
? "border-primary bg-primary"
: "border-muted-foreground/30 bg-transparent"
)}
>
{isSelected && (
<Check className="h-3 w-3 text-primary-foreground"/>
)}
</div>
<div className="flex items-start gap-3 pr-8">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<Database className="h-5 w-5 text-muted-foreground"/>
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate font-medium text-foreground">
{database.name}
</h3>
<p className="mt-0.5 text-sm text-muted-foreground">
{database.dbms}
</p>
</div>
</div>
</button>
)
})
)}
</div>
)}
</div>
</ScrollArea>
</div>
)
}
@@ -0,0 +1,38 @@
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {cn} from "@/lib/utils";
import {Agent} from "@/db/schema/08_agent";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {AgentCard} from "@/components/wrappers/dashboard/agent/agent-card/agent-card";
import {AgentDialog} from "@/features/agents/components/agent.dialog";
export type OrganizationAgentsTabProps = {
organization: OrganizationWithMembers;
agents: Agent[];
};
export const OrganizationAgentsTab = ({
organization,
agents,
}: OrganizationAgentsTabProps) => {
const hasAgent = agents.length > 0;
return (
<div className="flex flex-col gap-y-6 h-full py-4">
<div className="h-full flex flex-col gap-y-6">
<div className={cn("hidden flex-row justify-between items-start", hasAgent && "flex")}>
<div className="max-w-2xl ">
<h3 className="text-xl font-semibold text-balance mb-1">
Agent Settings
</h3>
</div>
<AgentDialog organization={organization} typeTrigger="create"/>
</div>
{hasAgent ? (
<CardsWithPagination data={agents} organizationView={true} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1}/>
) : (
<AgentDialog organization={organization} typeTrigger="empty"/>
)}
</div>
</div>
);
};
@@ -29,11 +29,6 @@ export const OrganizationNotifiersTab = ({
Notification Settings
</h3>
</div>
{/*<NotifierAddEditModal*/}
{/* organization={organization}*/}
{/* open={isAddModalOpen}*/}
{/* onOpenChangeAction={setIsAddModalOpen}*/}
{/*/>*/}
<ChannelAddEditModal
kind={kind}
organization={organization}
@@ -16,22 +16,32 @@ import {
import {
OrganizationStoragesTab
} from "@/components/wrappers/dashboard/organization/tabs/organization-channels-tab/organization-storages-tab";
import {
OrganizationAgentsTab
} from "@/components/wrappers/dashboard/organization/tabs/organization-channels-tab/organization-agents-tab";
import {Agent} from "@/db/schema/08_agent";
export type OrganizationTabsProps = {
organization: OrganizationWithMembers;
notificationChannels: NotificationChannel[];
storageChannels: StorageChannel[];
activeMember: MemberWithUser
activeMember: MemberWithUser;
agents: Agent[]
};
export const OrganizationTabs = ({activeMember, organization, notificationChannels, storageChannels}: OrganizationTabsProps) => {
export const OrganizationTabs = ({
activeMember,
organization,
notificationChannels,
storageChannels,
agents
}: OrganizationTabsProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "users");
const {
canManageUsers,
canManageNotifications,
canManageStorages
} = useOrganizationPermissions(activeMember);
@@ -71,6 +81,12 @@ export const OrganizationTabs = ({activeMember, organization, notificationChanne
>
Storages
</TabsTrigger>
<TabsTrigger
className="w-full"
value="agents"
>
Agents
</TabsTrigger>
</TabsList>
<TabsContent className="h-full" value="users">
<SettingsOrganizationMembersTable organization={organization}/>
@@ -87,6 +103,12 @@ export const OrganizationTabs = ({activeMember, organization, notificationChanne
storageChannels={storageChannels}
/>
</TabsContent>
<TabsContent className="h-full" value="agents">
<OrganizationAgentsTab
organization={organization}
agents={agents}
/>
</TabsContent>
</Tabs>
:
<SettingsOrganizationMembersTable organization={organization}/>
@@ -72,7 +72,7 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
<Avatar className="w-24 h-24 lg:w-32 lg:h-32 border-4 border-muted/20">
<AvatarImage className="object-cover" src={user.image || undefined}/>
<AvatarFallback className="text-3xl">{user.name.charAt(0)}</AvatarFallback>
<AvatarFallback className="text-3xl">{user.name.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<div
@@ -1,165 +1,171 @@
"use client";
import { DatabaseBackupActionsModal } from "@/components/wrappers/dashboard/database/backup/actions/backup-actions-modal";
import { DatabaseTabs } from "@/components/wrappers/dashboard/projects/database/database-tabs";
import { Setting } from "@/db/schema/01_setting";
import { BackupWith, DatabaseWith, Restoration } from "@/db/schema/07_database";
import { MemberWithUser } from "@/db/schema/03_organization";
import { useBackupModal } from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import { DatabaseKpi } from "@/components/wrappers/dashboard/projects/database/database-kpi";
import { useQuery } from "@tanstack/react-query";
import { getDatabaseDataAction } from "@/components/wrappers/dashboard/database/backup/actions/get-data.action";
import {DatabaseBackupActionsModal} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions-modal";
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs";
import {Setting} from "@/db/schema/01_setting";
import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database";
import {MemberWithUser} from "@/db/schema/03_organization";
import {useBackupModal} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
import {useQuery} from "@tanstack/react-query";
import {getDatabaseDataAction} from "@/components/wrappers/dashboard/database/backup/actions/get-data.action";
import {
PageContent,
PageDescription,
PageTitle,
PageContent,
PageDescription,
PageTitle,
} from "@/features/layout/page";
import { capitalizeFirstLetter } from "@/utils/text";
import { RetentionPolicySheet } from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
import { CronButton } from "@/components/wrappers/dashboard/database/cron-button/cron-button";
import { ChannelPoliciesModal } from "@/components/wrappers/dashboard/database/channels-policy/policy-modal";
import { HardDrive, Megaphone } from "lucide-react";
import { ImportModal } from "@/components/wrappers/dashboard/database/import/import-modal";
import { BackupButton } from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
import {capitalizeFirstLetter} from "@/utils/text";
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
import {ChannelPoliciesModal} from "@/components/wrappers/dashboard/database/channels-policy/policy-modal";
import {HardDrive, Megaphone} from "lucide-react";
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
import {HealthModal} from "@/components/wrappers/dashboard/database/health/health-modal";
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
import {Badge} from "@/components/ui/badge";
export type DatabaseContentProps = {
settings: Setting;
backups: BackupWith[];
restorations: Restoration[];
isAlreadyRestore: boolean;
database: DatabaseWith;
activeMember: MemberWithUser;
totalBackups: number;
availableBackups: number;
successRate: number | null;
organizationId: string;
activeOrganizationChannels: any[];
activeOrganizationStorageChannels: any[];
databaseHealthLogs: HealthcheckLog[]
settings: Setting;
backups: BackupWith[];
restorations: Restoration[];
isAlreadyRestore: boolean;
database: DatabaseWith;
activeMember: MemberWithUser;
totalBackups: number;
availableBackups: number;
successRate: number | null;
organizationId: string;
activeOrganizationChannels: any[];
activeOrganizationStorageChannels: any[];
databaseHealthLogs: HealthcheckLog[]
};
export const DatabaseContent = (props: DatabaseContentProps) => {
const {} = useBackupModal();
const {} = useBackupModal();
const { data } = useQuery({
queryKey: ["database-data", props.database.id],
queryFn: async () => {
const result = await getDatabaseDataAction({
databaseId: props.database.id,
});
return result?.data;
},
initialData: {
// TODO : to be patched
// @ts-ignore
database: {
...props.database,
project: props.database.project ?? null,
},
backups: props.backups,
restorations: props.restorations,
activeOrganizationChannels: props.activeOrganizationChannels,
activeOrganizationStorageChannels:
props.activeOrganizationStorageChannels,
stats: {
const {data} = useQuery({
queryKey: ["database-data", props.database.id],
queryFn: async () => {
const result = await getDatabaseDataAction({
databaseId: props.database.id,
});
return result?.data;
},
initialData: {
// TODO : to be patched
// @ts-ignore
database: {
...props.database,
project: props.database.project ?? null,
},
backups: props.backups,
restorations: props.restorations,
activeOrganizationChannels: props.activeOrganizationChannels,
activeOrganizationStorageChannels:
props.activeOrganizationStorageChannels,
stats: {
totalBackups: props.totalBackups,
availableBackups: props.availableBackups,
successRate: props.successRate,
},
health: props.databaseHealthLogs
},
staleTime: 0,
gcTime: 0,
refetchInterval: 1000,
});
const database = data?.database ?? props.database;
const backups = data?.backups ?? props.backups;
const restorations = data?.restorations ?? props.restorations;
const activeOrganizationChannels =
data?.activeOrganizationChannels ?? props.activeOrganizationChannels;
const activeOrganizationStorageChannels =
data?.activeOrganizationStorageChannels ??
props.activeOrganizationStorageChannels;
const stats = data?.stats ?? {
totalBackups: props.totalBackups,
availableBackups: props.availableBackups,
successRate: props.successRate,
},
health: props.databaseHealthLogs
},
staleTime: 0,
gcTime: 0,
refetchInterval: 1000,
});
};
const database = data?.database ?? props.database;
const backups = data?.backups ?? props.backups;
const restorations = data?.restorations ?? props.restorations;
const activeOrganizationChannels =
data?.activeOrganizationChannels ?? props.activeOrganizationChannels;
const activeOrganizationStorageChannels =
data?.activeOrganizationStorageChannels ??
props.activeOrganizationStorageChannels;
const stats = data?.stats ?? {
totalBackups: props.totalBackups,
availableBackups: props.availableBackups,
successRate: props.successRate,
};
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const isAlreadyBackup = backups.some(
(b) => b.status === "waiting" || b.status === "ongoing",
);
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const isAlreadyBackup = backups.some(
(b) => b.status === "waiting" || b.status === "ongoing",
);
const isMember = props.activeMember.role === "member";
const isMember = props.activeMember.role === "member";
return (
<>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(database.name)}
</div>
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<RetentionPolicySheet database={database} />
<CronButton database={database} />
<ChannelPoliciesModal
database={database}
kind={"notification"}
icon={<Megaphone />}
channels={activeOrganizationChannels}
organizationId={props.organizationId}
/>
<ChannelPoliciesModal
database={database}
icon={<HardDrive />}
kind={"storage"}
channels={activeOrganizationStorageChannels}
organizationId={props.organizationId}
/>
<ImportModal database={database} />
<HealthModal database={database} healthLogs={data?.health ?? []}/>
</div>
return (
<>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="flex min-w-full md:min-w-fit justify-between gap-2 items-center ">
{capitalizeFirstLetter(database.name)}
<div className=" flex items-center justify-center">
<Badge variant="outline" className="bg-orange-400/10 border-orange-600/50 text-orange-600">
{database.dbms}
</Badge>
</div>
</div>
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<RetentionPolicySheet database={database}/>
<CronButton database={database}/>
<ChannelPoliciesModal
database={database}
kind={"notification"}
icon={<Megaphone/>}
channels={activeOrganizationChannels}
organizationId={props.organizationId}
/>
<ChannelPoliciesModal
database={database}
icon={<HardDrive/>}
kind={"storage"}
channels={activeOrganizationStorageChannels}
organizationId={props.organizationId}
/>
<ImportModal database={database}/>
<HealthModal database={database} healthLogs={data?.health ?? []}/>
</div>
<div className="flex items-center gap-2">
<BackupButton
disable={isAlreadyBackup || !database.lastContact}
databaseId={database.id}
/>
</div>
<div className="flex items-center gap-2">
<BackupButton
disable={isAlreadyBackup || !database.lastContact}
databaseId={database.id}
/>
</div>
</div>
)}
</PageTitle>
</div>
)}
</PageTitle>
</div>
{database.description && (
<PageDescription className="mt-5 sm:mt-0">
{database.description}
</PageDescription>
)}
{database.description && (
<PageDescription className="mt-5 sm:mt-0">
{database.description}
</PageDescription>
)}
<PageContent className="flex flex-col w-full h-full">
<DatabaseKpi
successRate={stats.successRate}
database={database}
availableBackups={stats.availableBackups}
totalBackups={stats.totalBackups}
/>
<DatabaseBackupActionsModal />
<DatabaseTabs
activeMember={props.activeMember}
settings={props.settings}
database={database}
isAlreadyRestore={isAlreadyRestore}
backups={backups}
restorations={restorations}
/>
</PageContent>
</>
);
<PageContent className="flex flex-col w-full h-full">
<DatabaseKpi
successRate={stats.successRate}
database={database}
availableBackups={stats.availableBackups}
totalBackups={stats.totalBackups}
/>
<DatabaseBackupActionsModal/>
<DatabaseTabs
activeMember={props.activeMember}
settings={props.settings}
database={database}
isAlreadyRestore={isAlreadyRestore}
backups={backups}
restorations={restorations}
/>
</PageContent>
</>
);
};
+10
View File
@@ -0,0 +1,10 @@
CREATE TABLE "organization_agents" (
"organization_id" uuid NOT NULL,
"agent_id" uuid NOT NULL,
CONSTRAINT "organization_agents_organization_id_agent_id_unique" UNIQUE("organization_id","agent_id")
);
--> statement-breakpoint
ALTER TABLE "agents" ADD COLUMN "organization_id" uuid;--> statement-breakpoint
ALTER TABLE "organization_agents" ADD CONSTRAINT "organization_agents_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_agents" ADD CONSTRAINT "organization_agents_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "agents" ADD CONSTRAINT "agents_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
+31
View File
@@ -0,0 +1,31 @@
-- Custom SQL migration file, put your code below! --
DO $$
DECLARE
org RECORD;
proj RECORD;
db RECORD;
BEGIN
FOR org IN SELECT id FROM organization LOOP
FOR proj IN
SELECT id FROM projects WHERE organization_id = org.id
LOOP
FOR db IN
SELECT agent_id FROM databases WHERE project_id = proj.id
LOOP
IF db.agent_id IS NOT NULL THEN
INSERT INTO organization_agents (
organization_id,
agent_id
)
VALUES (
org.id,
db.agent_id
)
ON CONFLICT (organization_id, agent_id) DO NOTHING;
END IF;
END LOOP;
END LOOP;
END LOOP;
END $$;
@@ -0,0 +1,3 @@
ALTER TABLE "organization_agents" ADD COLUMN "updated_at" timestamp;--> statement-breakpoint
ALTER TABLE "organization_agents" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;--> statement-breakpoint
ALTER TABLE "organization_agents" ADD COLUMN "deleted_at" timestamp;
+4
View File
@@ -0,0 +1,4 @@
ALTER TABLE "two_factor" ADD COLUMN IF NOT EXISTS "verified" boolean DEFAULT false;
UPDATE "two_factor" SET "verified" = true WHERE "verified" = false;
ALTER TABLE "two_factor" ALTER COLUMN "verified" SET NOT NULL;
ALTER TABLE "two_factor" ALTER COLUMN "verified" DROP DEFAULT;
@@ -0,0 +1 @@
ALTER TABLE "backups" ADD COLUMN "migrated" boolean DEFAULT false;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+35
View File
@@ -344,6 +344,41 @@
"when": 1774886139855,
"tag": "0048_yellow_eddie_brock",
"breakpoints": true
},
{
"idx": 49,
"version": "7",
"when": 1775585355435,
"tag": "0049_chief_terrax",
"breakpoints": true
},
{
"idx": 50,
"version": "7",
"when": 1775761395545,
"tag": "0050_dark_saracen",
"breakpoints": true
},
{
"idx": 51,
"version": "7",
"when": 1775762288699,
"tag": "0051_young_senator_kelly",
"breakpoints": true
},
{
"idx": 52,
"version": "7",
"when": 1775803959723,
"tag": "0052_cute_punisher",
"breakpoints": true
},
{
"idx": 53,
"version": "7",
"when": 1777204042208,
"tag": "0053_lyrical_union_jack",
"breakpoints": true
}
]
}
+1
View File
@@ -97,6 +97,7 @@ export const twoFactor = pgTable("two_factor", {
id: uuid().defaultRandom().primaryKey(),
secret: text("secret").notNull(),
backupCodes: text("backup_codes").notNull(),
verified: boolean("verified").notNull(),
userId: uuid("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
+7 -1
View File
@@ -3,7 +3,7 @@ import {relations} from "drizzle-orm";
import {Organization, organization} from "./03_organization";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
import {Database, database} from "./07_database";
import {Database, database, DatabaseWith} from "./07_database";
import {timestamps} from "@/db/schema/00_common";
export const project = pgTable("projects", {
@@ -32,3 +32,9 @@ export type ProjectWith = Project & {
databases: Database[];
organization: Organization;
};
export type ProjectWithDatabasesAndBackups = Project & {
databases: DatabaseWith[];
organization: Organization;
};
+3 -2
View File
@@ -1,5 +1,5 @@
import {pgTable, text, boolean, timestamp, uuid, integer, pgEnum} from "drizzle-orm/pg-core";
import {Agent, agent} from "./08_agent";
import {Agent, agent, AgentWith} from "./08_agent";
import {Project, project} from "./06_project";
import {relations} from "drizzle-orm";
import {dbmsEnum, statusEnum} from "./types";
@@ -42,6 +42,7 @@ export const backup = pgTable(
.notNull()
.references(() => database.id, {onDelete: "cascade"}),
imported: boolean('imported').default(false),
migrated: boolean('migrated').default(false),
...timestamps
},
);
@@ -123,7 +124,7 @@ export type RetentionPolicy = z.infer<typeof retentionPolicySchema>;
export type DatabaseWith = Database & {
agent?: Agent | null;
agent?: Agent | AgentWith | null;
project?: Project | null;
backups?: Backup[] | null;
restorations?: Restoration[] | null;
+36 -1
View File
@@ -1,9 +1,10 @@
import {boolean, pgTable, text, timestamp, uuid, integer} from "drizzle-orm/pg-core";
import {boolean, pgTable, text, timestamp, uuid, integer, unique} from "drizzle-orm/pg-core";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
import {Database, database} from "@/db/schema/07_database";
import {relations} from "drizzle-orm";
import {timestamps} from "@/db/schema/00_common";
import {organization} from "@/db/schema/03_organization";
export const agent = pgTable("agents", {
id: uuid("id").primaryKey().defaultRandom(),
@@ -14,22 +15,56 @@ export const agent = pgTable("agents", {
description: text("description").notNull(),
isArchived: boolean("is_archived").default(false),
lastContact: timestamp("last_contact"),
organizationId: uuid("organization_id").references(() => organization.id, {onDelete: "cascade"}),
...timestamps
});
export const organizationAgent = pgTable(
"organization_agents",
{
organizationId: uuid('organization_id')
.notNull()
.references(() => organization.id, {onDelete: 'cascade'}),
agentId: uuid('agent_id')
.notNull()
.references(() => agent.id, {onDelete: 'cascade'}),
...timestamps
},
(t) => [unique().on(t.organizationId, t.agentId)]
);
export const agentSchema = createSelectSchema(agent);
export type Agent = z.infer<typeof agentSchema>;
export const agentRelations = relations(agent, ({many}) => ({
databases: many(database),
organizations: many(organizationAgent),
}));
export const organizationAgentRelations = relations(organizationAgent, ({one}) => ({
organization: one(organization, {
fields: [organizationAgent.organizationId],
references: [organization.id],
}),
agent: one(agent, {
fields: [organizationAgent.agentId],
references: [agent.id],
}),
}));
export type AgentWith = Agent & {
databases?: Database[] | null;
organizations: {
organizationId: string;
agentId: string;
}[];
};
export type AgentWithDatabases = Agent & {
databases: Database[] | [];
};
-3
View File
@@ -4,8 +4,6 @@ import {organization} from "@/db/schema/03_organization";
import {relations} from "drizzle-orm";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
import {database} from "@/db/schema/07_database";
export const providerStorageKindEnum = pgEnum('provider_storage_kind', ['local', 's3', 'google-drive']);
@@ -32,7 +30,6 @@ export const organizationStorageChannel = pgTable(
(t) => [unique().on(t.organizationId, t.storageChannelId)]
);
export const storageChannelRelations = relations(storageChannel, ({many}) => ({
organizations: many(organizationStorageChannel),
}));
+1 -4
View File
@@ -1,13 +1,10 @@
import {boolean, pgTable, uuid} from "drizzle-orm/pg-core";
import {timestamps} from "@/db/schema/00_common";
import {relations} from "drizzle-orm";
import {Backup, Database, database, Restoration, RetentionPolicy} from "@/db/schema/07_database";
import {database} from "@/db/schema/07_database";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
import {StorageChannel, storageChannel} from "@/db/schema/12_storage-channel";
import {Agent} from "@/db/schema/08_agent";
import {Project} from "@/db/schema/06_project";
import {AlertPolicy} from "@/db/schema/10_alert-policy";
export const storagePolicy = pgTable('storage_policy', {
id: uuid('id').defaultRandom().primaryKey(),
+43
View File
@@ -0,0 +1,43 @@
import {and, desc, eq, sql} from "drizzle-orm";
import {db} from "@/db";
import {Agent, agent, organizationAgent} from "@/db/schema/08_agent";
import {Database, database} from "@/db/schema/07_database";
export async function getOrganizationAgents(organizationId: string) {
return await db
.select({
id: agent.id,
name: agent.name,
organizationId: agent.organizationId,
slug: agent.slug,
healthErrorCount: agent.healthErrorCount,
description: agent.description,
isArchived: agent.isArchived,
lastContact: agent.lastContact,
version: agent.version,
updatedAt: agent.updatedAt,
createdAt: agent.createdAt,
deletedAt: agent.deletedAt,
databases: sql<Database[]>`
COALESCE(
json_agg(${database}.*) FILTER (WHERE ${database}.id IS NOT NULL),
'[]'
)
`,
})
.from(organizationAgent)
.innerJoin(
agent,
eq(organizationAgent.agentId, agent.id)
)
.leftJoin(database, eq(database.agentId, agent.id))
.groupBy(agent.id)
.orderBy(desc(agent.createdAt))
.where(
and(
eq(organizationAgent.organizationId, organizationId),
eq(agent.isArchived, false)
)
) as unknown as Agent[];
}
+39
View File
@@ -0,0 +1,39 @@
"use server"
import {db} from "@/db";
import {DatabaseWith} from "@/db/schema/07_database";
import {AgentWith} from "@/db/schema/08_agent";
export async function getOrganizationAvailableDatabases(
organizationId: string,
projectId?: string
) {
const availableDatabases = (
await db.query.database.findMany({
where: (db, { eq, or, isNull }) =>
projectId
? or(isNull(db.projectId), eq(db.projectId, projectId))
: isNull(db.projectId),
with: {
agent: {
with: {
organizations: true
}
},
project: true,
backups: true,
restorations: true,
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
) as DatabaseWith[];
return availableDatabases.filter(db => {
const agent = db.agent as AgentWith;
if (agent?.isArchived) return false;
return (
agent?.organizationId === organizationId ||
agent?.organizations?.some(org => org.organizationId === organizationId)
);
})
}
+17 -5
View File
@@ -10,18 +10,30 @@ import {getHealthLast12hLogs} from "@/db/services/healthcheck";
const verifySlugUniqueness = async (slug: string, agentId?: string) => {
const conditions = agentId ? and(eq(drizzleDb.schemas.agent.slug, slug), ne(drizzleDb.schemas.agent.id, agentId)) : eq(drizzleDb.schemas.agent.slug, slug);
const [countResult] = await db.select({count: count()}).from(drizzleDb.schemas.agent).where(conditions);
if (countResult.count > 0) {
throw new ActionError("Slug already exists");
}
};
export const createAgentAction = userAction.schema(AgentSchema).action(async ({parsedInput}) => {
const slug = slugify(parsedInput.name);
export const createAgentAction = userAction.schema(
z.object({
organizationId: z.string().optional(),
data: AgentSchema,
})
).action(async ({parsedInput}) => {
const slug = slugify(parsedInput.data.name);
await verifySlugUniqueness(slug);
const [createdAgent] = await db.insert(drizzleDb.schemas.agent).values({...parsedInput, slug: slug}).returning();
const [createdAgent] = await db.insert(drizzleDb.schemas.agent).values({...parsedInput.data, slug: slug, organizationId: parsedInput.organizationId}).returning();
if (createdAgent && parsedInput.organizationId){
await db.insert(drizzleDb.schemas.organizationAgent).values({
organizationId: parsedInput.organizationId,
agentId: createdAgent.id,
});
}
return {
data: createdAgent,
};
+1
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
export const AgentSchema = z.object({
name: z.string().nonempty("Name is required"),
description: z.string(),
});
export type AgentType = z.infer<typeof AgentSchema>;
@@ -0,0 +1,129 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import {db} from "@/db";
import {and, eq, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {AgentWith} from "@/db/schema/08_agent";
import {withUpdatedAt} from "@/db/utils";
export const updateAgentOrganizationsAction = userAction
.schema(
z.object({
data: z.array(z.string()),
id: z.string(),
})
)
.action(async ({parsedInput , ctx}): Promise<ServerActionResult<null>> => {
try {
const organizationsIds = parsedInput.data;
const agentId = parsedInput.id;
const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId),
with: {
organizations: true,
databases: true
}
}) as AgentWith;
if (!agent) {
return {
success: false,
actionError: {
message: "Agent not found.",
status: 404,
cause: "not_found",
},
};
}
const existingItemIds = agent.organizations.map((organization) => organization.organizationId);
const organizationsToAdd = organizationsIds.filter((id) => !existingItemIds.includes(id));
const organizationsToRemove = existingItemIds.filter((id) => !organizationsIds.includes(id));
if (organizationsToAdd.length > 0) {
for (const organizationToAdd of organizationsToAdd) {
await db.insert(drizzleDb.schemas.organizationAgent).values({
organizationId: organizationToAdd,
agentId: agentId
});
}
}
if (organizationsToRemove.length > 0) {
await db.delete(drizzleDb.schemas.organizationAgent).where(and(inArray(drizzleDb.schemas.organizationAgent.organizationId, organizationsToRemove), eq(drizzleDb.schemas.organizationAgent.agentId,agentId))).execute();
const organizationsToRemoveDetails = await db.query.organization.findMany({
where: inArray(drizzleDb.schemas.organization.id, organizationsToRemove),
with: {
projects: true
}
});
const projectIds = organizationsToRemoveDetails.flatMap(org =>
org.projects.map(project => project.id)
);
if (projectIds.length > 0) {
const databases = await db.query.database.findMany({
where: (db, { inArray }) => inArray(db.projectId, projectIds),
columns: { id: true }
});
const databaseIds = databases.map(d => d.id);
await db
.update(drizzleDb.schemas.database)
.set(withUpdatedAt({
backupPolicy: null,
projectId: null
}))
.where(inArray(drizzleDb.schemas.database.projectId, projectIds))
.execute();
await db.delete(drizzleDb.schemas.retentionPolicy)
.where(inArray(drizzleDb.schemas.retentionPolicy.databaseId, databaseIds))
.execute();
await db.delete(drizzleDb.schemas.alertPolicy)
.where(inArray(drizzleDb.schemas.alertPolicy.databaseId, databaseIds))
.execute();
await db.delete(drizzleDb.schemas.storagePolicy)
.where(inArray(drizzleDb.schemas.storagePolicy.databaseId, databaseIds))
.execute();
}
}
return {
success: true,
value: null,
actionSuccess: {
message: "Agent organizations has been successfully updated.",
messageParams: {agentId: agentId},
},
};
} catch (error) {
console.error("Error updating agent organizations:", error);
return {
success: false,
actionError: {
message: "Failed to update agent organizations.",
status: 500,
cause: "server_error",
messageParams: {message: "Error updating the agent organizations"},
},
};
}
});
@@ -0,0 +1,106 @@
"use client";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {Form, FormControl, FormField, FormItem, useZodForm} from "@/components/ui/form";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select";
import {toast} from "sonner";
import {AgentWith} from "@/db/schema/08_agent";
import {AgentOrganizationSchema, AgentOrganizationType} from "@/features/agents/components/agent-organizations.schema";
import {updateAgentOrganizationsAction} from "@/features/agents/components/agent-organizations.action";
type AgentOrganisationFormProps = {
organizations?: OrganizationWithMembers[];
defaultValues?: AgentWith
};
export const AgentOrganisationForm = ({
organizations,
defaultValues,
}: AgentOrganisationFormProps) => {
const router = useRouter();
const defaultOrganizationIds = defaultValues?.organizations?.map(organization => organization.organizationId) ?? []
const form = useZodForm({
schema: AgentOrganizationSchema,
// @ts-ignore
defaultValues: {
organizations: defaultOrganizationIds
},
});
const formatOrganizationsList = (organizations: OrganizationWithMembers[]) => {
return organizations
.map((organization) => ({
value: organization.id,
label: `${organization.name}`,
}));
};
const mutation = useMutation({
mutationFn: async (values: AgentOrganizationType) => {
const payload = {
data: values.organizations,
id: defaultValues?.id ?? ""
};
const result = await updateAgentOrganizationsAction(payload)
const inner = result?.data;
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
router.refresh();
} else {
toast.error(inner?.actionError?.message);
}
}
});
return (
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name={`organizations`}
render={({field}) => (
<FormItem>
<FormControl>
<MultiSelect
options={formatOrganizationsList(organizations ?? [])}
onValueChange={field.onChange}
defaultValue={field.value ?? []}
placeholder="Select organization(s)"
variant="inverted"
animation={0}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex justify-end">
<div className="flex gap-2 justify-end">
<ButtonWithLoading isPending={mutation.isPending}>
Save
</ButtonWithLoading>
</div>
</div>
</Form>
);
};
@@ -0,0 +1,7 @@
import {z} from "zod";
export const AgentOrganizationSchema = z.object({
organizations: z.array(z.string().uuid())
});
export type AgentOrganizationType = z.infer<typeof AgentOrganizationSchema>;
+49 -13
View File
@@ -10,19 +10,24 @@ import {
import {AgentForm} from "@/features/agents/components/agent.form";
import {Button, buttonVariants} from "@/components/ui/button";
import {Plus} from "lucide-react";
import {AgentType} from "@/features/agents/agents.schema";
import {GearIcon} from "@radix-ui/react-icons";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
import {useState} from "react";
import {useRouter} from "next/navigation";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {AgentOrganisationForm} from "@/features/agents/components/agent-organizations.form";
import {AgentWith} from "@/db/schema/08_agent";
type AgentDialogProps = {
agent?: AgentType & { id: string };
agent?: AgentWith;
typeTrigger: "edit" | "empty" | "create";
organization?: OrganizationWithMembers;
adminView?: boolean,
organizations?: OrganizationWithMembers[];
};
export const AgentDialog = ({agent, typeTrigger}: AgentDialogProps) => {
export const AgentDialog = ({agent, typeTrigger, organization, adminView, organizations}: AgentDialogProps) => {
const [open, setOpen] = useState(false);
const isEdit = !!agent;
const router = useRouter();
@@ -36,7 +41,7 @@ export const AgentDialog = ({agent, typeTrigger}: AgentDialogProps) => {
</div>
);
case "empty":
return <EmptyStatePlaceholder text="Create new Agent"/>;
return <EmptyStatePlaceholder className="h-full" text="Create new Agent"/>;
case "create":
return <Button><Plus className="mr-2 h-4 w-4"/> Create Agent</Button>;
default:
@@ -53,14 +58,45 @@ export const AgentDialog = ({agent, typeTrigger}: AgentDialogProps) => {
<DialogHeader>
<DialogTitle>{isEdit ? `Edit ${agent.name}` : "Create new agent"}</DialogTitle>
</DialogHeader>
<AgentForm
onSuccess={() => {
setOpen(false)
router.refresh()
}}
defaultValues={agent}
agentId={agent?.id}
/>
<>
{adminView ?
<Tabs className="flex flex-col flex-1" defaultValue="configuration">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="configuration">Configuration</TabsTrigger>
<TabsTrigger value="organizations">Organizations</TabsTrigger>
</TabsList>
<TabsContent className="h-full justify-between" value="configuration">
<AgentForm
organization={organization}
onSuccess={() => {
setOpen(false)
router.refresh()
}}
defaultValues={agent}
agentId={agent?.id}
/>
</TabsContent>
<TabsContent className="h-full justify-between" value="organizations">
<AgentOrganisationForm
defaultValues={agent}
organizations={organizations}
/>
</TabsContent>
</Tabs>
:
<>
<AgentForm
organization={organization}
onSuccess={() => {
setOpen(false)
router.refresh()
}}
defaultValues={agent}
agentId={agent?.id}
/>
</>
}
</>
</DialogContent>
</Dialog>
);
@@ -18,11 +18,14 @@ import {TooltipProvider} from "@/components/ui/tooltip";
import {AgentSchema, AgentType} from "@/features/agents/agents.schema";
import {toast} from "sonner";
import {createAgentAction, updateAgentAction} from "@/features/agents/agents.action";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
export type agentFormProps = {
defaultValues?: AgentType;
agentId?: string;
onSuccess?: (data: any) => void;
organization?: OrganizationWithMembers;
};
export const AgentForm = (props: agentFormProps) => {
@@ -40,7 +43,10 @@ export const AgentForm = (props: agentFormProps) => {
mutationFn: async (values: AgentType) => {
const createAgent = isCreate
? await createAgentAction(values)
? await createAgentAction({
organizationId: props.organization?.id ?? undefined,
data: values
})
: await updateAgentAction({
id: props.agentId ?? "-",
data: values,
@@ -61,7 +67,7 @@ export const AgentForm = (props: agentFormProps) => {
if (props.onSuccess) {
props.onSuccess(data);
} else {
router.push(`/dashboard/agents/${data.id}`);
router.push(props.organization ?`/dashboard/settings/agents/${data.id}` : `/dashboard/agents/${data.id}`);
}
},
});
@@ -60,6 +60,7 @@ export function backupColumns(
cell: ({row}) => {
const reference = row.original.id
const isImported = row.original.imported
const isMigrated = row.original.migrated
return (
<div className="flex items-center space-x-2">
<span>{reference}</span>
@@ -68,6 +69,11 @@ export function backupColumns(
Imported
</BadgeC>
)}
{isMigrated && (
<BadgeC variant="outline" className="bg-blue-400/10 border-blue-600/50 text-blue-600">
Migrated
</BadgeC>
)}
</div>
)
},
@@ -1,5 +1,12 @@
"use server"
import {StorageDeleteInput, StorageGetInput, StorageMetaData, StorageResult, StorageUploadInput} from '../../types';
import {
StorageCopyInput,
StorageDeleteInput,
StorageGetInput,
StorageMetaData,
StorageResult,
StorageUploadInput
} from '../../types';
import {GoogleDriveConfig} from "@/features/storages/providers/google-drive/types";
import {
ensureFolderPath,
@@ -144,4 +151,71 @@ export async function pingGoogleDrive(config: GoogleDriveConfig): Promise<Storag
} catch (err: any) {
return {success: false, provider: "google-drive", response: err.message};
}
}
export async function copyGoogleDrive(
config: GoogleDriveConfig,
input: {
data: StorageCopyInput,
metadata?: StorageMetaData;
},
): Promise<StorageResult> {
const client = await getGoogleDriveClient(config);
const sourceFileId = await resolveFilePath(
client,
input.data.from,
config.folderId,
);
if (!sourceFileId) {
return {
success: false,
provider: "google-drive",
error: "Source file not found",
};
}
const fullPath = input.data.to;
const parts = fullPath.split("/").filter(Boolean);
const fileName = parts.pop()!;
const folderPath = parts.join("/");
const folderId = folderPath
? await ensureFolderPath(client, folderPath, config.folderId)
: config.folderId;
try {
const copied = await client.files.copy({
fileId: sourceFileId,
requestBody: {
name: fileName,
parents: [folderId],
},
fields: "id",
supportsAllDrives: true,
});
const newFileId = copied.data.id;
if (!newFileId) {
return {
success: false,
provider: "google-drive",
error: "Copy failed (no file id returned)",
};
}
return {
success: true,
provider: "google-drive",
};
} catch (err: any) {
return {
success: false,
provider: "google-drive",
error: err.message || "Copy failed",
};
}
}
+7 -2
View File
@@ -4,9 +4,10 @@ import {
StorageResult,
} from '../types';
import {uploadLocal, getLocal, deleteLocal, pingLocal} from './local';
import {deleteS3, getS3, pingS3, uploadS3} from "@/features/storages/providers/s3";
import {uploadLocal, getLocal, deleteLocal, pingLocal, copyLocal} from './local';
import {copyS3, deleteS3, getS3, pingS3, uploadS3} from "@/features/storages/providers/s3";
import {
copyGoogleDrive,
deleteGoogleDrive,
getGoogleDrive,
pingGoogleDrive,
@@ -18,6 +19,7 @@ type ProviderHandler = {
get: (config: any, input: StorageInput & { action: 'get' }) => Promise<StorageResult>;
delete: (config: any, input: StorageInput & { action: 'delete' }) => Promise<StorageResult>;
ping: (config: any, input: { action: 'ping' }) => Promise<StorageResult>;
copy: (config: any, input: StorageInput & { action: 'copy' }) => Promise<StorageResult>;
};
const handlers: Record<StorageProviderKind, ProviderHandler> = {
@@ -26,18 +28,21 @@ const handlers: Record<StorageProviderKind, ProviderHandler> = {
get: getLocal,
delete: deleteLocal,
ping: pingLocal,
copy: copyLocal,
},
s3: {
upload: uploadS3,
get: getS3,
delete: deleteS3,
ping: pingS3,
copy: copyS3
},
"google-drive": {
upload: uploadGoogleDrive,
get: getGoogleDrive,
delete: deleteGoogleDrive,
ping: pingGoogleDrive,
copy: copyGoogleDrive,
}
};
+57 -1
View File
@@ -2,6 +2,7 @@
import { mkdir, unlink } from "fs/promises";
import path from "path";
import {
StorageCopyInput,
StorageDeleteInput,
StorageGetInput,
StorageMetaData,
@@ -13,7 +14,7 @@ import { generateFileUrl } from "@/features/storages/helpers";
import { Readable } from "node:stream";
import { env } from "@/env.mjs";
const BASE_DIR = path.join(env.PRIVATE_PATH, "/uploads");
const BASE_DIR = path.join(env.PRIVATE_PATH!, "/uploads");
export async function uploadLocal(
config: { baseDir?: string },
@@ -159,3 +160,58 @@ export async function pingLocal(config: {
response: "Local storage OK",
};
}
export async function copyLocal(
config: { baseDir?: string },
input: {
data: StorageCopyInput,
metadata?: StorageMetaData;
},
): Promise<StorageResult> {
const base = config.baseDir
? path.join(process.cwd(), config.baseDir ?? "")
: BASE_DIR;
const sourcePath = path.join(base, input.data.from);
const destinationPath = path.join(base, input.data.to);
const dir = path.dirname(destinationPath);
await mkdir(dir, { recursive: true });
if (!fs.existsSync(sourcePath)) {
return {
success: false,
provider: "local",
error: "Source file not found",
};
}
try {
await new Promise<void>((resolve, reject) => {
const readStream = fs.createReadStream(sourcePath);
const writeStream = fs.createWriteStream(destinationPath);
readStream.on("error", reject);
writeStream.on("error", reject);
writeStream.on("finish", resolve);
readStream.pipe(writeStream);
});
return {
success: true,
provider: "local",
};
} catch (err: any) {
try {
await unlink(destinationPath);
} catch {}
return {
success: false,
provider: "local",
error: err.message || "Copy failed",
};
}
}
+41 -1
View File
@@ -1,5 +1,12 @@
import * as Minio from "minio";
import {StorageDeleteInput, StorageGetInput, StorageMetaData, StorageResult, StorageUploadInput} from "../types";
import {
StorageCopyInput,
StorageDeleteInput,
StorageGetInput,
StorageMetaData,
StorageResult,
StorageUploadInput
} from "../types";
import {Readable} from "node:stream";
type S3Config = {
@@ -132,3 +139,36 @@ export async function pingS3(config: S3Config): Promise<StorageResult> {
};
}
}
export async function copyS3(
config: S3Config,
input: {
data: StorageCopyInput,
},
): Promise<StorageResult> {
const client = await getS3Client(config);
await ensureBucket(config);
const sourceKey = `${BASE_DIR}${input.data.from}`;
const destinationKey = `${BASE_DIR}${input.data.to}`;
try {
await client.copyObject(
config.bucketName,
destinationKey,
`/${config.bucketName}/${sourceKey}`
);
return {
success: true,
provider: "s3",
};
} catch (err: any) {
return {
success: false,
provider: "s3",
error: err.message,
};
}
}
+8 -1
View File
@@ -38,11 +38,18 @@ export interface StorageDeleteInput {
path: string;
}
export interface StorageCopyInput {
from: string;
to: string;
}
export type StorageInput =
| { action: 'upload'; data: StorageUploadInput, metadata?: StorageMetaData }
| { action: 'get'; data: StorageGetInput, metadata: StorageMetaData }
| { action: 'delete'; data: StorageDeleteInput, metadata?: StorageMetaData }
| { action: 'ping'; };
| { action: 'ping'; }
| { action: 'copy'; data: StorageCopyInput, metadata?: StorageMetaData };
export interface StorageResult {
success: boolean;
+2
View File
@@ -7,6 +7,7 @@ export type OrganizationPermissions = {
isAdmin: boolean;
isMember: boolean;
canManageAgents: boolean;
canManageSettings: boolean;
canManageUsers: boolean;
canManageNotifications: boolean;
@@ -31,6 +32,7 @@ export const computeOrganizationPermissions = (
isMember,
canManageSettings: isOwner || isAdmin,
canManageAgents: isOwner || isAdmin,
canManageUsers: isOwner || isAdmin,
canManageNotifications: isOwner || isAdmin,
canManageStorages: isOwner || isAdmin,
+1 -1
View File
@@ -30,7 +30,7 @@ export const auth = betterAuth({
onAPIError: {
errorURL: "/error",
onError: (error, ctx) => {
//todo: capture errors in a monitoring service
//TODO: capture errors in a monitoring service
},
},
database: drizzleAdapter(db, {
+37 -3
View File
@@ -17,7 +17,7 @@ export function buildOrganizationWithMembers(
const org = rows[0].organization;
const invitations : OrganizationInvitation[] = rows
const invitations: OrganizationInvitation[] = rows
.filter(r => r.invitation)
.map(r => ({
...r.invitation!,
@@ -38,7 +38,6 @@ export function buildOrganizationWithMembers(
}
export function getFileExtension(dbType: string) {
switch (dbType) {
case "postgresql":
@@ -48,4 +47,39 @@ export function getFileExtension(dbType: string) {
default:
return ".dump";
}
}
}
export function getFileHeadersBasedOnDbms(dbType: string): Record<string, string[]> {
switch (dbType) {
case "postgresql":
return {
"application/octet-stream": [".dump"],
};
case "mysql":
case "mariadb":
return {
"application/sql": [".sql"],
"application/x-sql": [".sql"],
};
case "mongodb":
return {
"application/gzip": [".archive.gz"],
};
case "firebird":
return {
"application/octet-stream": [".fbk"],
};
case "valkey":
case "redis":
return {
"application/octet-stream": [".rdb"],
};
case "sqlite":
return {
"application/octet-stream": [".backup"],
};
default:
throw new Error(`Unsupported database type: ${dbType}`);
}
}
+8
View File
@@ -47,3 +47,11 @@ export function formatDayOnly(date: Date) {
}).format(date);
}
export function getTodayISODate() {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
+2 -1
View File
@@ -6,6 +6,7 @@ import {cleaningHealthcheckLogsJob, cleaningJob, healthcheckAgentAndDatabaseJob,
import { generateRSAKeys, getOrCreateMasterKey } from "@/utils/rsa-keys";
import { StorageProviderKind } from "@/features/storages/types";
import {logger} from "@/lib/logger";
import {withUpdatedAt} from "@/db/utils";
const log = logger.child({module: "init"});
@@ -103,7 +104,7 @@ async function createSettingsIfNotExist() {
if (!finalSystemSetting.defaultStorageChannelId) {
await tx
.update(drizzleDb.schemas.setting)
.set({ defaultStorageChannelId: localStorage.id })
.set(withUpdatedAt({ defaultStorageChannelId: localStorage.id }))
.where(eq(drizzleDb.schemas.setting.id, finalSystemSetting.id));
}
});