mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f74f378c0b | ||
|
|
385836d5fe | ||
|
|
461e3ff7c9 | ||
|
|
54a233b207 | ||
|
|
2d2453acf7 | ||
|
|
654ff6b9a5 | ||
|
|
34ff11f377 | ||
|
|
b95e145a8b | ||
|
|
bce6c9d55f | ||
|
|
c3e2978919 | ||
|
|
2d559e456d | ||
|
|
45e3b31fda | ||
|
|
c8da2579e8 | ||
|
|
c7e30e1659 | ||
|
|
7be23876aa | ||
|
|
98f1b329ae | ||
|
|
19ea5543ea | ||
|
|
010396ba4d | ||
|
|
f055744432 | ||
|
|
1920b29a55 | ||
|
|
a0c8d1fb68 | ||
|
|
59dc3b71b8 | ||
|
|
598eb44952 | ||
|
|
ac49047177 | ||
|
|
4448c9e166 | ||
|
|
d70778b556 | ||
|
|
66edb77113 | ||
|
|
0f7b3eae11 | ||
|
|
2f9e33f4db | ||
|
|
dd7a65262c | ||
|
|
4c0d2b1f7f | ||
|
|
60728885b6 | ||
|
|
7ac99a6618 | ||
|
|
4ed91c1297 | ||
|
|
44be8009fd | ||
|
|
96630a0ef7 | ||
|
|
8c9d694b40 | ||
|
|
1f09c1507a | ||
|
|
141241d555 | ||
|
|
c605e8173b | ||
|
|
37eb8f319f | ||
|
|
0492d244b3 | ||
|
|
baeec9f7a0 | ||
|
|
449c9c2cfe | ||
|
|
d13e4b7d40 | ||
|
|
13241811d0 | ||
|
|
4b52dc4a1e | ||
|
|
63cb6cb7a3 | ||
|
|
0511ad289c | ||
|
|
057a51499e | ||
|
|
1f01d9c372 | ||
|
|
0684a4887a | ||
|
|
96be14e1ba | ||
|
|
eec3c2737b | ||
|
|
a6b960ffb2 | ||
|
|
d3f465b5d4 | ||
|
|
2139dfd55d | ||
|
|
6562718635 |
@@ -0,0 +1,28 @@
|
||||
name: Security Checks
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
sca-deps: # Dependency & container scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: aquasecurity/trivy-action@0.20.0
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
format: 'table'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
ignore-unfixed: true
|
||||
|
||||
|
||||
secrets-gitleaks: # Secrets exposure
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Fetch full history for gitleaks
|
||||
- uses: gitleaks/gitleaks-action@v2
|
||||
with:
|
||||
config-path: .gitleaks.toml # Optional, if you have a custom config
|
||||
@@ -0,0 +1,10 @@
|
||||
title = "Custom gitleaks config"
|
||||
|
||||
[allowlist]
|
||||
# Global allowlist patterns (won’t be flagged)
|
||||
regexes = [
|
||||
"s3SecretAccessKey: env.S3_SECRET_KEY ?? null",
|
||||
]
|
||||
paths = [
|
||||
"src/utils/init.ts"
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
repos:
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.24.2
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
Binary file not shown.
@@ -12,14 +12,21 @@
|
||||
<a href="https://portabase.io"><strong>Explore the Docs »</strong></a>
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://portabase.io">View Demo</a>
|
||||
<a href="https://www.youtube.com/watch?v=D9uFrGxLc4s">View Demo</a>
|
||||
·
|
||||
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a>
|
||||
·
|
||||
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
|
||||
|
||||

|
||||
|
||||
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
## 📚 Table of Contents
|
||||
|
||||
- [About The Project](#about-the-project)
|
||||
@@ -185,14 +192,6 @@ major.minor.patch-rc.release-tag
|
||||
# Example: 1.0.0-rc.1-dev
|
||||
```
|
||||
|
||||
### Zenstack Support
|
||||
|
||||
To regenerate schema:
|
||||
|
||||
```bash
|
||||
zenstack generate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
@@ -15,13 +15,10 @@ export default function Layout({children}: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
|
||||
|
||||
|
||||
if (session && session.user && !session.user.banned && session.user.role !== "pending") {
|
||||
router.replace("/dashboard/home");
|
||||
}
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { PageParams } from "@/types/next";
|
||||
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
|
||||
import { AdminTabs } from "@/components/wrappers/dashboard/admin/admin-tabs";
|
||||
import { db } from "@/db";
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {AdminTabs} from "@/components/wrappers/dashboard/admin/admin-tabs";
|
||||
import {db} from "@/db";
|
||||
import {isNull} from "drizzle-orm";
|
||||
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const users = await db.query.user.findMany({
|
||||
where: (fields) => isNull(fields.deletedAt)
|
||||
where: (fields) => isNull(fields.deletedAt),
|
||||
with: {
|
||||
accounts: true
|
||||
}
|
||||
});
|
||||
|
||||
const settings = await db.query.setting.findFirst({
|
||||
where: (fields, { eq }) => eq(fields.name, "system"),
|
||||
where: (fields, {eq}) => eq(fields.name, "system"),
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -21,7 +24,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
<PageTitle>Administration Panel</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<AdminTabs settings={settings!} users={users} />
|
||||
<AdminTabs settings={settings!} users={users}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -38,7 +38,6 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
||||
// const databaseId = 'db-123';
|
||||
//
|
||||
|
||||
console.log("agent",agent)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
import {and, eq, not} from "drizzle-orm";
|
||||
import {Plus} from "lucide-react";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
@@ -38,12 +41,10 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
{agents.length > 0 ? (
|
||||
<CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1} />
|
||||
) : (
|
||||
<Link
|
||||
href={"/dashboard/agents/new"}
|
||||
className=" flex item-center justify-center border-2 border-dashed transition-colors border-primary p-8 lg:p-12 w-full rounded-md"
|
||||
>
|
||||
Create new Agent
|
||||
</Link>
|
||||
<EmptyStatePlaceholder
|
||||
url={"/dashboard/agents/new"}
|
||||
text={"Create new Agent"}
|
||||
/>
|
||||
)}
|
||||
</PageContent>
|
||||
</Page>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { PageParams } from "@/types/next";
|
||||
import {Page, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Dashboard</PageTitle>
|
||||
</PageHeader>
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div className="aspect-video rounded-xl bg-muted/50" />
|
||||
<div className="aspect-video rounded-xl bg-muted/50" />
|
||||
<div className="aspect-video rounded-xl bg-muted/50" />
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
|
||||
);
|
||||
}
|
||||
+1
@@ -27,6 +27,7 @@ export default async function RoutePage(props: PageParams<{ databaseId: string }
|
||||
<PageContent>
|
||||
<DatabaseForm
|
||||
databaseId={databaseId}
|
||||
// @ts-ignore
|
||||
defaultValues={{ ...dbItem, dbms: dbItem.dbms ?? "inactive", description: dbItem.description ?? undefined }}
|
||||
/>
|
||||
</PageContent>
|
||||
|
||||
+69
-28
@@ -1,5 +1,5 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {notFound} from "next/navigation";
|
||||
import {notFound, redirect} from "next/navigation";
|
||||
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
|
||||
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs";
|
||||
@@ -8,18 +8,40 @@ import {EditButton} from "@/components/wrappers/dashboard/database/edit-button/e
|
||||
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
|
||||
|
||||
import {db} from "@/db";
|
||||
import {eq, and} from "drizzle-orm";
|
||||
import {eq, and, inArray} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {getOrganizationProjectDatabases} from "@/lib/services";
|
||||
import {getOrganization} from "@/lib/auth/auth";
|
||||
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ databaseId: string }>) {
|
||||
const {databaseId} = await props.params;
|
||||
export default async function RoutePage(props: PageParams<{
|
||||
projectId: string;
|
||||
databaseId: string
|
||||
}>) {
|
||||
const {projectId, databaseId} = await props.params;
|
||||
|
||||
const organization = await getOrganization({});
|
||||
|
||||
if (!organization) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const databasesProject = await getOrganizationProjectDatabases({
|
||||
organizationSlug: organization.slug,
|
||||
projectId: projectId
|
||||
})
|
||||
|
||||
const dbItem = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.id, databaseId),
|
||||
where: and(inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []), eq(drizzleDb.schemas.database.id, databaseId), eq(drizzleDb.schemas.database.projectId, projectId)),
|
||||
with: {
|
||||
project: true,
|
||||
retentionPolicy: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!dbItem) {
|
||||
notFound();
|
||||
redirect("/dashboard/projects");
|
||||
}
|
||||
|
||||
const backups = await db.query.backup.findMany({
|
||||
@@ -38,37 +60,56 @@ export default async function RoutePage(props: PageParams<{ databaseId: string }
|
||||
const isAlreadyBackup = backups.some((b) => b.status === "waiting");
|
||||
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
|
||||
|
||||
const [totalBackups, successfulBackups] = await Promise.all([
|
||||
db
|
||||
.select({count: drizzleDb.schemas.backup.id})
|
||||
.from(drizzleDb.schemas.backup)
|
||||
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
|
||||
.then((rows) => rows.length),
|
||||
db
|
||||
.select({count: drizzleDb.schemas.backup.id})
|
||||
.from(drizzleDb.schemas.backup)
|
||||
.where(and(eq(drizzleDb.schemas.backup.databaseId, dbItem.id), eq(drizzleDb.schemas.backup.status, "success")))
|
||||
.then((rows) => rows.length),
|
||||
]);
|
||||
const totalBackups = await db.select({count: drizzleDb.schemas.backup.id})
|
||||
.from(drizzleDb.schemas.backup)
|
||||
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
|
||||
.then(rows => rows.length);
|
||||
|
||||
const availableBackups = backups.filter(b => !b.deletedAt).length;
|
||||
|
||||
const successfulBackups = await db.select({count: drizzleDb.schemas.backup.id})
|
||||
.from(drizzleDb.schemas.backup)
|
||||
.where(and(
|
||||
eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
|
||||
eq(drizzleDb.schemas.backup.status, "success")
|
||||
))
|
||||
.then(rows => rows.length);
|
||||
|
||||
|
||||
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
||||
if (!settings) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex items-center">
|
||||
{dbItem.name}
|
||||
<EditButton/>
|
||||
<CronButton database={dbItem}/>
|
||||
<PageTitle className="flex items-center justify-between w-full">
|
||||
{capitalizeFirstLetter(dbItem.name)}
|
||||
<div className="flex items-center gap-2 justify-between w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<EditButton/>
|
||||
<RetentionPolicySheet database={dbItem}/>
|
||||
<CronButton database={dbItem}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
|
||||
</div>
|
||||
</div>
|
||||
</PageTitle>
|
||||
<PageActions className="justify-between">
|
||||
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
|
||||
</PageActions>
|
||||
</div>
|
||||
<PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription>
|
||||
|
||||
{dbItem.description && (
|
||||
<PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription>
|
||||
)}
|
||||
<PageContent className="flex flex-col w-full h-full">
|
||||
<DatabaseKpi successRate={successRate} database={dbItem} totalBackups={totalBackups} />
|
||||
<DatabaseTabs database={dbItem} isAlreadyRestore={isAlreadyRestore} backups={backups} restorations={restorations} />
|
||||
<DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
|
||||
totalBackups={totalBackups}/>
|
||||
<DatabaseTabs settings={settings} database={dbItem} isAlreadyRestore={isAlreadyRestore}
|
||||
backups={backups}
|
||||
restorations={restorations}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as drizzleDb from "@/db";
|
||||
|
||||
import {db} from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {DatabaseWith} from "@/db/schema/06_database";
|
||||
import {DatabaseWith} from "@/db/schema/07_database";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ projectId: string }>) {
|
||||
const {projectId} = await props.params;
|
||||
@@ -42,10 +42,9 @@ export default async function RoutePage(props: PageParams<{ projectId: string }>
|
||||
},
|
||||
orderBy: (db, {desc}) => [desc(db.createdAt)],
|
||||
})
|
||||
);
|
||||
// .filter((db): db is DatabaseWith => db.project !== null);
|
||||
)
|
||||
// .filter((db) => db.project !== null) as DatabaseWith[];
|
||||
|
||||
console.log("ici34",availableDatabases);
|
||||
|
||||
|
||||
return (
|
||||
@@ -57,7 +56,7 @@ export default async function RoutePage(props: PageParams<{ projectId: string }>
|
||||
<ProjectForm
|
||||
organization={org}
|
||||
databases={availableDatabases as DatabaseWith[]}
|
||||
defaultValues={{name: proj.name, slug: proj.slug, databases: proj.databases.map((db) => db.id)}}
|
||||
defaultValues={{...proj, databases: proj.databases.map((db) => db.id)}}
|
||||
projectId={proj.id}
|
||||
/>
|
||||
</PageContent>
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
import { PageParams } from "@/types/next";
|
||||
import { Page, PageActions, PageContent, PageDescription, PageTitle } from "@/features/layout/page";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { GearIcon } from "@radix-ui/react-icons";
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {buttonVariants} from "@/components/ui/button";
|
||||
import {GearIcon} from "@radix-ui/react-icons";
|
||||
import Link from "next/link";
|
||||
import { ButtonDeleteProject } from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
|
||||
import { CardsWithPagination } from "@/components/wrappers/common/cards-with-pagination";
|
||||
import { ProjectDatabaseCard } from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
ButtonDeleteProject
|
||||
} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
|
||||
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 {db} from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {getOrganization} from "@/lib/auth/auth";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{
|
||||
// slug: string;
|
||||
projectId: string
|
||||
}>) {
|
||||
const {
|
||||
// slug: organizationSlug,
|
||||
projectId } = await props.params;
|
||||
projectId
|
||||
} = await props.params;
|
||||
|
||||
const organization = await getOrganization({});
|
||||
|
||||
if (!organization) {
|
||||
notFound();
|
||||
}
|
||||
@@ -33,26 +34,32 @@ export default async function RoutePage(props: PageParams<{
|
||||
if (!org) notFound();
|
||||
|
||||
const proj = await db.query.project.findFirst({
|
||||
where: (proj, { and, eq, not }) => and(eq(proj.id, projectId), eq(proj.organizationId, org.id), not(eq(proj.isArchived, true))),
|
||||
where: (proj, {
|
||||
and,
|
||||
eq,
|
||||
not
|
||||
}) => and(eq(proj.id, projectId), eq(proj.organizationId, org.id), not(eq(proj.isArchived, true))),
|
||||
with: {
|
||||
databases: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!proj) notFound();
|
||||
if (!proj) {
|
||||
redirect("/dashboard/projects");
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex items-center">
|
||||
{proj.name}
|
||||
<Link className={buttonVariants({ variant: "outline" })} href={`/dashboard/projects/${proj.id}/edit`}>
|
||||
<GearIcon className="w-7 h-7" />
|
||||
{capitalizeFirstLetter(proj.name)}
|
||||
<Link className={buttonVariants({variant: "outline"})} href={`/dashboard/projects/${proj.id}/edit`}>
|
||||
<GearIcon className="w-7 h-7"/>
|
||||
</Link>
|
||||
</PageTitle>
|
||||
<PageActions className="justify-between">
|
||||
<ButtonDeleteProject projectId={projectId} text={"Delete Project"} />
|
||||
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
|
||||
</PageActions>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {db} from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {getOrganization} from "@/lib/auth/auth";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {DatabaseWith} from "@/db/schema/06_database";
|
||||
import {DatabaseWith} from "@/db/schema/07_database";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ }>) {
|
||||
|
||||
@@ -27,7 +27,7 @@ export default async function RoutePage(props: PageParams<{ }>) {
|
||||
},
|
||||
orderBy: (db, {desc}) => [desc(db.createdAt)],
|
||||
})
|
||||
).filter((db) => db.project !== null) as DatabaseWith[];
|
||||
).filter((db) => db.project == null) as DatabaseWith[];
|
||||
|
||||
const org = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.slug, organization.slug),
|
||||
@@ -35,6 +35,7 @@ export default async function RoutePage(props: PageParams<{ }>) {
|
||||
|
||||
if (!org) notFound();
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {ProjectCard} from "@/components/wrappers/dashboard/projects/project-card
|
||||
import {db} from "@/db";
|
||||
import {notFound} from "next/navigation";
|
||||
import {getOrganization} from "@/lib/auth/auth";
|
||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ }>) {
|
||||
|
||||
@@ -46,12 +47,10 @@ export default async function RoutePage(props: PageParams<{ }>) {
|
||||
<CardsWithPagination organizationSlug={organization.slug} data={projects} cardItem={ProjectCard}
|
||||
cardsPerPage={4} numberOfColumns={1}/>
|
||||
) : (
|
||||
<Link
|
||||
href={`/dashboard/projects/new`}
|
||||
className=" flex item-center justify-center border-2 border-dashed transition-colors border-primary p-8 lg:p-12 w-full rounded-md"
|
||||
>
|
||||
Create new Project
|
||||
</Link>
|
||||
<EmptyStatePlaceholder
|
||||
url={"/dashboard/projects/new"}
|
||||
text={"Create new Project"}
|
||||
/>
|
||||
)}
|
||||
</PageContent>
|
||||
</Page>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageDescription, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {Page, PageActions, 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";
|
||||
@@ -17,7 +17,6 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||
const user = await currentUser();
|
||||
const activeMember = await getActiveMember()
|
||||
|
||||
|
||||
if (!organization) {
|
||||
notFound();
|
||||
}
|
||||
@@ -28,19 +27,18 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle className="flex items-center">
|
||||
Organization settings
|
||||
{!isMember && organization.slug !== "default" && (
|
||||
<EditButtonSettings />
|
||||
<EditButtonSettings/>
|
||||
)}
|
||||
</PageTitle>
|
||||
<PageActions>
|
||||
{!isMember && organization.slug !== "default" && (
|
||||
<DeleteOrganizationButton organizationSlug={organization.slug} />
|
||||
<DeleteOrganizationButton organizationSlug={organization.slug}/>
|
||||
)}
|
||||
</PageActions>
|
||||
</PageHeader>
|
||||
@@ -48,7 +46,7 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||
{/* Manage your organization settings.*/}
|
||||
{/*</PageDescription>*/}
|
||||
<PageContent>
|
||||
<SettingsOrganizationMembersTable organization={organization} />
|
||||
<SettingsOrganizationMembersTable organization={organization}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import {EvolutionLineChart} from "@/components/wrappers/dashboard/statistics/cha
|
||||
import {PercentageLineChart} from "@/components/wrappers/dashboard/statistics/charts/percentage-line-chart";
|
||||
import {notFound} from "next/navigation";
|
||||
import {db} from "@/db";
|
||||
import {asc, count, eq, inArray} from "drizzle-orm";
|
||||
import {and, asc, count, eq, inArray} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {getOrganization} from "@/lib/auth/auth";
|
||||
import {DatabaseBackup, Folder, RefreshCcw} from "lucide-react";
|
||||
@@ -46,6 +46,78 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
|
||||
|
||||
// const tomorrow = new Date();
|
||||
// tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
//
|
||||
// const before = new Date();
|
||||
// before.setDate(before.getDate() - 1);
|
||||
//
|
||||
// const backupsEvolution = [
|
||||
// {
|
||||
// id: '22e84aa4-228c-45b3-82ec-846a639cd509',
|
||||
// createdAt: new Date()
|
||||
// },
|
||||
// {
|
||||
// id: '2c114dc3-1fa6-4ef1-972c-9765c65e9331',
|
||||
// createdAt: new Date()
|
||||
// },
|
||||
// {
|
||||
// id: '2c114dc3-1fa6-4ef1-972c-9765c65e9331',
|
||||
// createdAt: new Date()
|
||||
// },
|
||||
// {
|
||||
// id: '2c114dc3-1fa6-4ef1-972c-9765c65e9331',
|
||||
// createdAt: new Date()
|
||||
// },
|
||||
// {
|
||||
// id: '2c114dc3-1fa6-4ef1-972c-9765c65e9331',
|
||||
// createdAt: new Date(before)
|
||||
// },
|
||||
// {
|
||||
// id: '6a6106fe-7f45-48eb-a56f-0a1e734126a1',
|
||||
// createdAt: new Date(before)
|
||||
// },
|
||||
// {
|
||||
// id: 'a529d790-502e-4609-ad37-9b1c00c73477',
|
||||
// createdAt: new Date()
|
||||
// },
|
||||
// {
|
||||
// id: 'a8c105a8-3e29-423e-b7dd-d3218092cde1',
|
||||
// createdAt: new Date()
|
||||
// },
|
||||
// {
|
||||
// id: 'd33aaf4f-8525-4490-addb-12e3c8650d6d',
|
||||
// createdAt: new Date()
|
||||
// },
|
||||
// {
|
||||
// id: 'f1d5a4e2-1c33-41c4-932b-02456c2a6f1d',
|
||||
// createdAt: new Date(tomorrow)
|
||||
// },
|
||||
// {
|
||||
// id: 'f1d5a4e2-1c33-41c4-932b-02456c2a6f1d',
|
||||
// createdAt: new Date(tomorrow)
|
||||
// },
|
||||
// {
|
||||
// id: 'e88a588a-2353-4470-9976-8c3eb2ffc88d',
|
||||
// createdAt: new Date()
|
||||
// },
|
||||
// {
|
||||
// id: 'ee11441e-4b41-4c1b-9d91-929565b4204a',
|
||||
// createdAt: new Date()
|
||||
// },
|
||||
//
|
||||
// // Entries with tomorrow's date
|
||||
// {
|
||||
// id: 'f1d5a4e2-1c33-41c4-932b-02456c2a6f1d',
|
||||
// createdAt: new Date(tomorrow)
|
||||
// },
|
||||
// {
|
||||
// id: 'c0a8323d-9241-4896-9e64-01e905c24e51',
|
||||
// createdAt: new Date(tomorrow)
|
||||
// }
|
||||
// ];
|
||||
|
||||
|
||||
const backupsRate = await db
|
||||
.select({
|
||||
createdAt: drizzleDb.schemas.backup.createdAt,
|
||||
@@ -53,7 +125,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
_count: count(),
|
||||
})
|
||||
.from(drizzleDb.schemas.backup)
|
||||
.where(inArray(drizzleDb.schemas.backup.status, ["success", "failed"]))
|
||||
.where(and(inArray(drizzleDb.schemas.backup.status, ["success", "failed"]), inArray(drizzleDb.schemas.backup.databaseId, databaseIds)))
|
||||
.groupBy(drizzleDb.schemas.backup.createdAt, drizzleDb.schemas.backup.status)
|
||||
.orderBy(drizzleDb.schemas.backup.createdAt);
|
||||
|
||||
@@ -73,6 +145,10 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
const backupsEvolutionCount = backupsEvolution.length;
|
||||
|
||||
|
||||
const sortedBackupsEvolution = backupsEvolution.sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
@@ -108,7 +184,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
<CardTitle>Evolution of the number of backups</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EvolutionLineChart data={backupsEvolution}/>
|
||||
<EvolutionLineChart data={sortedBackupsEvolution}/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="w-full">
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
|
||||
import {Building2, DatabaseBackup, Folder, RefreshCcw} from "lucide-react";
|
||||
import {EvolutionLineChart} from "@/components/wrappers/dashboard/statistics/charts/evolution-line-chart";
|
||||
import {PercentageLineChart} from "@/components/wrappers/dashboard/statistics/charts/percentage-line-chart";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {notFound} from "next/navigation";
|
||||
import {db} from "@/db";
|
||||
import {asc, eq, inArray} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {auth, listOrganizations} from "@/lib/auth/auth";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const user = await currentUser();
|
||||
const organizations = await listOrganizations()
|
||||
|
||||
if (!user || !organizations) notFound();
|
||||
|
||||
const organizationIds = organizations.map(project => project.id);
|
||||
|
||||
|
||||
const projects = await db.query.project.findMany({
|
||||
where: inArray(drizzleDb.schemas.project.organizationId, organizationIds),
|
||||
});
|
||||
|
||||
const projectIds = projects.map(project => project.id);
|
||||
|
||||
|
||||
const databasesOfAllProjects = await db.query.database.findMany({
|
||||
where: inArray(drizzleDb.schemas.database.projectId, projectIds),
|
||||
})
|
||||
const databaseIds = databasesOfAllProjects.map((database) => database.id);
|
||||
|
||||
|
||||
const backupsEvolution = await db.query.backup.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: [asc(drizzleDb.schemas.backup.id)],
|
||||
where: inArray(drizzleDb.schemas.backup.databaseId, databaseIds),
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Dashboard</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent className="flex flex-col gap-y-4">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<Card className="w-full flex-1">
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<Building2 className="w-5 h-5 text-muted-foreground"/>
|
||||
<CardTitle>Organizations</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold">{organizations.length}</CardContent>
|
||||
</Card>
|
||||
<Card className="w-full flex-1">
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<Folder className="w-5 h-5 text-muted-foreground"/>
|
||||
<CardTitle>Projects</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold">{projects.length}</CardContent>
|
||||
</Card>
|
||||
<Card className="w-full flex-1">
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<DatabaseBackup className="w-5 h-5 text-muted-foreground"/>
|
||||
<CardTitle>Backups</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold">{backupsEvolution.length}</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div className="aspect-video rounded-xl bg-muted/50"/>
|
||||
<div className="aspect-video rounded-xl bg-muted/50"/>
|
||||
<div className="aspect-video rounded-xl bg-muted/50"/>
|
||||
</div>
|
||||
</div>
|
||||
</PageContent>
|
||||
</Page>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -6,10 +6,10 @@ import {Badge} from "@/components/ui/badge";
|
||||
import {ButtonDeleteAccount} from "@/components/wrappers/dashboard/profile/button-delete-account/button-delete-account";
|
||||
import {AvatarWithUpload} from "@/components/wrappers/dashboard/profile/avatar/avatar-with-upload";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {getAccounts, getSessions} from "@/lib/auth/auth";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
const user = await currentUser();
|
||||
console.log("my user",user);
|
||||
if (!user) {
|
||||
return notFound();
|
||||
}
|
||||
@@ -18,8 +18,8 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
// const sessions = await getSessions();
|
||||
// const accounts = await getAccounts();
|
||||
const sessions = await getSessions();
|
||||
const accounts = await getAccounts();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
@@ -43,18 +43,19 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
{/* <ButtonDeleteAccount text="Delete my account"/>*/}
|
||||
{/*</PageActions>*/}
|
||||
</div>
|
||||
<PageContent>
|
||||
<PageContent >
|
||||
<UserForm
|
||||
userId={user.id}
|
||||
sessions={sessions} accounts={accounts}
|
||||
defaultValues={{
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role ?? undefined,
|
||||
}}
|
||||
/>
|
||||
<div className="mt-4 sm:hidden">
|
||||
<ButtonDeleteAccount text="Delete my account"/>
|
||||
</div>
|
||||
{/*<div className="mt-4 sm:hidden ">*/}
|
||||
{/* <ButtonDeleteAccount text="Delete my account"/>*/}
|
||||
{/*</div>*/}
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {uploadLocalPrivate} from "@/features/upload/private/upload.action";
|
||||
import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import {eventEmitter} from "../../../events/route";
|
||||
import {db} from "@/db";
|
||||
import {Backup} from "@/db/schema/06_database";
|
||||
import {Backup} from "@/db/schema/07_database";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {env} from "@/env.mjs";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
@@ -48,6 +50,9 @@ export async function POST(
|
||||
|
||||
const database = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
|
||||
with: {
|
||||
project: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!database) {
|
||||
@@ -108,7 +113,19 @@ export async function POST(
|
||||
const fileName = `${uuid}.dump`;
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
const {success, message, filePath} = await uploadLocalPrivate(fileName, buffer);
|
||||
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
||||
if (!settings) {
|
||||
throw new Error("System settings not found.");
|
||||
}
|
||||
|
||||
let success: boolean, message: string, filePath: string;
|
||||
|
||||
const result =
|
||||
settings.storage === "local"
|
||||
? await uploadLocalPrivate(fileName, buffer)
|
||||
: await uploadS3Private(`${database.project?.slug}/${fileName}`, buffer, env.S3_BUCKET_NAME!);
|
||||
|
||||
({success, message, filePath} = result);
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json(
|
||||
@@ -119,10 +136,10 @@ export async function POST(
|
||||
|
||||
await db
|
||||
.update(drizzleDb.schemas.backup)
|
||||
.set({
|
||||
.set(withUpdatedAt({
|
||||
file: fileName,
|
||||
status: 'success',
|
||||
})
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
@@ -137,7 +154,7 @@ export async function POST(
|
||||
|
||||
await db
|
||||
.update(drizzleDb.schemas.backup)
|
||||
.set({status: 'failed'})
|
||||
.set(withUpdatedAt({status: 'failed'}))
|
||||
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
@@ -26,7 +26,6 @@ export async function POST(
|
||||
const body: BodyResultRestore = await request.json();
|
||||
|
||||
console.log(body)
|
||||
console.log(agentId)
|
||||
|
||||
|
||||
if (!isUuidv4(body.generatedId)) {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {Body} from "./route";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {getFileUrlPresignedLocal} from "@/features/upload/private/upload.action";
|
||||
import {Agent} from "@/db/schema/07_agent";
|
||||
import {Database} from "@/db/schema/06_database";
|
||||
import {getFileUrlPresignedLocal, getFileUrlPreSignedS3Action} from "@/features/upload/private/upload.action";
|
||||
import {Agent} from "@/db/schema/08_agent";
|
||||
import {Database} from "@/db/schema/07_database";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db as dbClient} from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {SafeActionResult} from "next-safe-action";
|
||||
import {ZodString} from "zod";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
|
||||
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date) {
|
||||
const databasesResponse = [];
|
||||
@@ -35,17 +39,15 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
|
||||
let backupAction: boolean = false
|
||||
let restoreAction: boolean = false
|
||||
let UrlBackup: string = ""
|
||||
let urlBackup: string = ""
|
||||
|
||||
if (!existingDatabase) {
|
||||
if (!isUuidv4(db.generatedId)) {
|
||||
return NextResponse.json(
|
||||
{ error: "generatedId is not a valid uuid" },
|
||||
{ status: 500 }
|
||||
{error: "generatedId is not a valid uuid"},
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
console.log(db)
|
||||
console.log(dbmsEnumSchema.parse(db.dbms))
|
||||
const [databaseCreated] = await dbClient
|
||||
.insert(drizzleDb.schemas.database)
|
||||
.values({
|
||||
@@ -58,62 +60,108 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
.returning();
|
||||
|
||||
if (databaseCreated) {
|
||||
databasesResponse.push(formatDatabase(databaseCreated, backupAction,restoreAction, UrlBackup));
|
||||
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup));
|
||||
}
|
||||
} else {
|
||||
|
||||
|
||||
|
||||
const [databaseUpdated] = await dbClient
|
||||
.update(drizzleDb.schemas.database)
|
||||
.set({ lastContact: lastContact })
|
||||
.set({lastContact: lastContact})
|
||||
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
|
||||
.returning();
|
||||
|
||||
|
||||
|
||||
const backup = await dbClient.query.backup.findFirst({
|
||||
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.status,"waiting"))
|
||||
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.status, "waiting"))
|
||||
})
|
||||
|
||||
|
||||
const restoration = await dbClient.query.restoration.findFirst({
|
||||
where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status,"waiting"))
|
||||
where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting"))
|
||||
})
|
||||
|
||||
|
||||
if(backup){
|
||||
if (backup) {
|
||||
backupAction = true
|
||||
|
||||
await dbClient
|
||||
.update(drizzleDb.schemas.backup)
|
||||
.set({ status: "ongoing" })
|
||||
.set(withUpdatedAt({status: "ongoing"}))
|
||||
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||
}
|
||||
|
||||
if(restoration){
|
||||
if (restoration) {
|
||||
restoreAction = true
|
||||
|
||||
|
||||
const backupToRestore = await dbClient.query.backup.findFirst({
|
||||
where: eq(drizzleDb.schemas.backup.id, restoration.backupId),
|
||||
with: {
|
||||
database: {
|
||||
with: {
|
||||
project: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const [settings] = await dbClient.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
||||
if (!settings) {
|
||||
return NextResponse.json(
|
||||
{error: "Unable to find settings"},
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const fileName = backupToRestore?.file
|
||||
UrlBackup = await getFileUrlPresignedLocal(fileName ?? "")
|
||||
|
||||
let data: SafeActionResult<string, ZodString, readonly [], {
|
||||
_errors?: string[] | undefined;
|
||||
}, readonly [], ServerActionResult<string>, object> | undefined
|
||||
|
||||
try {
|
||||
|
||||
if (settings.storage == "local") {
|
||||
data = await getFileUrlPresignedLocal(fileName!)
|
||||
} else if (settings.storage == "s3") {
|
||||
|
||||
data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`);
|
||||
}
|
||||
|
||||
|
||||
if (data?.data?.success) {
|
||||
urlBackup = data.data.value ?? "";
|
||||
} else {
|
||||
await dbClient
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
.set({status: "failed"})
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
|
||||
// @ts-ignore
|
||||
const errorMessage = data?.data?.actionError?.message || "Failed to get presigned URL";
|
||||
console.error("Restoration failed: ", errorMessage);
|
||||
|
||||
continue;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Restoration crashed unexpectedly:", err);
|
||||
await dbClient
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
.set({status: "failed"})
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
|
||||
continue;
|
||||
}
|
||||
await dbClient
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
.set({ status: "ongoing" })
|
||||
.set({status: "ongoing"})
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
|
||||
}
|
||||
|
||||
|
||||
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, UrlBackup));
|
||||
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup));
|
||||
}
|
||||
}
|
||||
|
||||
return databasesResponse;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export type Body = {
|
||||
databases: databaseAgent[]
|
||||
}
|
||||
|
||||
|
||||
// Function to test the get file url presigned local
|
||||
export async function GET(request: Request) {
|
||||
const url = await getFileUrlPresignedLocal("d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump")
|
||||
return Response.json({
|
||||
|
||||
@@ -2,9 +2,17 @@ import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET({ params }: { params: Promise<{ fileName: string }> }) {
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{params}: { params: Promise<{ fileName: string }> }
|
||||
) {
|
||||
|
||||
try {
|
||||
const fileName = (await params).fileName;
|
||||
|
||||
|
||||
console.log("fileName", fileName);
|
||||
|
||||
const filePath = path.join(process.cwd(), "private/uploads/images", fileName);
|
||||
|
||||
// Check if the file exists
|
||||
|
||||
+2
-1
@@ -1,10 +1,10 @@
|
||||
import React from "react";
|
||||
import type {Metadata} from "next";
|
||||
import {Inter} from "next/font/google";
|
||||
|
||||
import "./globals.css";
|
||||
import {Providers} from "./providers";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer";
|
||||
|
||||
const inter = Inter({subsets: ["latin"]});
|
||||
|
||||
@@ -24,6 +24,7 @@ export default function RootLayout({
|
||||
<meta name="apple-mobile-web-app-title" content="Portabase"/>
|
||||
</head>
|
||||
<body className={cn(inter.className, "h-full")}>
|
||||
<ConsoleSilencer/>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+4
-1
@@ -23,7 +23,7 @@ services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
@@ -53,16 +53,19 @@ services:
|
||||
# - db
|
||||
|
||||
s3:
|
||||
container_name: s3-portabase-dev
|
||||
image: docker.io/bitnami/minio:latest
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
|
||||
environment:
|
||||
- MINIO_ROOT_USER=${S3_ACCESS_KEY}
|
||||
- MINIO_ROOT_PASSWORD=${S3_SECRET_KEY}
|
||||
- MINIO_DEFAULT_BUCKETS=${S3_BUCKET_NAME}
|
||||
- MINIO_BROWSER=on
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo " ____ __ __ "
|
||||
echo " / __ \____ _____/ /_____ _/ /_ ____ _________ "
|
||||
echo " / /_/ / __ \/ ___/ __/ __ / __ \/ __ / ___/ _ \ "
|
||||
echo " / ____/ /_/ / / / /_/ /_/ / /_/ / /_/ (__ ) __/ "
|
||||
echo " /_/ \____/_/ \__/\__,_/_.___/\__,_/____/\___/ "
|
||||
echo " "
|
||||
echo " Community Edition v1.0.0 "
|
||||
echo " "
|
||||
|
||||
#echo " ____ __ __ "
|
||||
#echo " / __ \____ _____/ /_____ _/ /_ ____ _________ "
|
||||
#echo " / /_/ / __ \/ ___/ __/ __ / __ \/ __ / ___/ _ \ "
|
||||
#echo " / ____/ /_/ / / / /_/ /_/ / /_/ / /_/ (__ ) __/ "
|
||||
#echo " /_/ \____/_/ \__/\__,_/_.___/\__,_/____/\___/ "
|
||||
#echo " "
|
||||
#echo " Community Edition v1.1.1 "
|
||||
#echo " "
|
||||
|
||||
node server.js
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
const init = await import("@/utils/init");
|
||||
init.init();
|
||||
await init.init();
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -44,12 +44,6 @@ const nextConfig: NextConfig = {
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
// compiler: {
|
||||
// removeConsole: process.env.NODE_ENV === "production",
|
||||
// },
|
||||
experimental: {
|
||||
nodeMiddleware: true,
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "portabase",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 8887",
|
||||
@@ -65,9 +65,10 @@
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.510.0",
|
||||
"minio": "^8.0.5",
|
||||
"next": "15.4.2-canary.10",
|
||||
"next": "15.5.2",
|
||||
"next-safe-action": "^7.10.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^7.0.3",
|
||||
"npm-check-updates": "^18.0.1",
|
||||
"pg": "^8.16.0",
|
||||
|
||||
@@ -13,6 +13,7 @@ export const PORTABASE_DEFAULT_SETTINGS = {
|
||||
"https://code.iconify.com",
|
||||
"https://cdn.iconify.design",
|
||||
"https://api.iconify.design",
|
||||
|
||||
],
|
||||
STYLE_SRC: [
|
||||
"'self'",
|
||||
@@ -31,6 +32,7 @@ export const PORTABASE_DEFAULT_SETTINGS = {
|
||||
"https://cdn.iconify.design",
|
||||
"https://code.iconify.com",
|
||||
"https://api.iconify.design",
|
||||
"http://localhost:9000",
|
||||
],
|
||||
FONT_SRC: [
|
||||
"'self'",
|
||||
|
||||
@@ -35,6 +35,8 @@ const buttonVariants = cva(
|
||||
}
|
||||
)
|
||||
|
||||
export type ButtonVariantsProps = VariantProps<typeof buttonVariants>;
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
|
||||
+75
-73
@@ -11,119 +11,121 @@ function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l",
|
||||
// "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r",
|
||||
// "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export type VariantButton = {
|
||||
secondary: string;
|
||||
default: string;
|
||||
outline: string;
|
||||
ghost: string;
|
||||
link: string;
|
||||
destructive: string;
|
||||
};
|
||||
// "use client";
|
||||
//
|
||||
// import { Button } from "@/components/ui/button";
|
||||
// import { useState } from "react";
|
||||
// import { Loader2 } from "lucide-react";
|
||||
//
|
||||
// export type VariantButton = {
|
||||
// secondary: string;
|
||||
// default: string;
|
||||
// outline: string;
|
||||
// ghost: string;
|
||||
// link: string;
|
||||
// destructive: string;
|
||||
// };
|
||||
//
|
||||
// export type ButtonWithConfirmProps = {
|
||||
// icon?: any;
|
||||
// text: string;
|
||||
// variant?: keyof VariantButton;
|
||||
// className?: string;
|
||||
// onClick?: () => void;
|
||||
// isPending?: boolean;
|
||||
// };
|
||||
//
|
||||
// export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
|
||||
// const [isConfirming, setIsConfirming] = useState(false);
|
||||
//
|
||||
// return (
|
||||
// <Button
|
||||
// onClick={() => {
|
||||
// if (isConfirming && props.onClick) {
|
||||
// props.onClick();
|
||||
// } else {
|
||||
// setIsConfirming(true);
|
||||
// }
|
||||
// }}
|
||||
// variant={props.variant ? props.variant : "default"}
|
||||
// className={props.className}
|
||||
// >
|
||||
// {props.isPending && <Loader2 className="animate-spin mr-4" size={16} />}
|
||||
// {isConfirming ? "Are you sure ?" : `${props.text}`}
|
||||
// <>{props.icon ? props.icon : null}</>
|
||||
// </Button>
|
||||
// );
|
||||
// };
|
||||
"use client"
|
||||
import {Button, ButtonVariantsProps} from "@/components/ui/button";
|
||||
import {useState} from "react";
|
||||
import {Loader2} from "lucide-react";
|
||||
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip";
|
||||
|
||||
export type ButtonWithConfirmProps = {
|
||||
icon?: any;
|
||||
text: string;
|
||||
variant?: keyof VariantButton;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
title: string;
|
||||
description: string;
|
||||
button: {
|
||||
main: {
|
||||
className?: string;
|
||||
text?: string;
|
||||
icon?: any;
|
||||
variant?: ButtonVariantsProps["variant"];
|
||||
size?: ButtonVariantsProps["size"];
|
||||
disabled?: boolean;
|
||||
tooltipText?: string;
|
||||
};
|
||||
confirm: {
|
||||
className?: string;
|
||||
text: string;
|
||||
icon?: any;
|
||||
variant?: ButtonVariantsProps["variant"];
|
||||
size?: ButtonVariantsProps["size"];
|
||||
onClick?: () => void;
|
||||
};
|
||||
cancel: {
|
||||
className?: string;
|
||||
text: string;
|
||||
icon?: any;
|
||||
variant?: ButtonVariantsProps["variant"];
|
||||
size?: ButtonVariantsProps["size"];
|
||||
onClick?: () => void;
|
||||
};
|
||||
};
|
||||
isPending?: boolean;
|
||||
};
|
||||
|
||||
|
||||
export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (isConfirming && props.onClick) {
|
||||
props.onClick();
|
||||
} else {
|
||||
setIsConfirming(true);
|
||||
}
|
||||
}}
|
||||
variant={props.variant ? props.variant : "default"}
|
||||
className={props.className}
|
||||
>
|
||||
{props.isPending && <Loader2 className="animate-spin mr-4" size={16} />}
|
||||
{isConfirming ? "Are you sure ?" : `${props.text}`}
|
||||
<>{props.icon ? props.icon : null}</>
|
||||
</Button>
|
||||
<Popover open={isConfirming} onOpenChange={!props.button.main.disabled ? setIsConfirming : undefined}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex",
|
||||
props.button.main.disabled ? "cursor-not-allowed" : ""
|
||||
)}
|
||||
role="button">
|
||||
<Button
|
||||
disabled={!!props.button.main.disabled}
|
||||
variant={props.button.main.variant ?? "default"}
|
||||
size={props.button.main.size ?? "default"}
|
||||
className={props.button.main.className}
|
||||
onClick={() => {
|
||||
if (!props.button.main.disabled) setIsConfirming(true);
|
||||
}}
|
||||
>
|
||||
{props.button.main.icon}
|
||||
{props.button.main.text && <span>{props.button.main.text}</span>}
|
||||
</Button>
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
{props.button.main.tooltipText && props.button.main.disabled && (
|
||||
<TooltipContent>
|
||||
<p>{props.button.main.tooltipText}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-80">
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium leading-none">{props.title}</h4>
|
||||
<p className="text-sm text-muted-foreground">{props.description}</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (isConfirming && props.button.confirm.onClick) {
|
||||
props.button.confirm.onClick();
|
||||
setIsConfirming(false);
|
||||
} else {
|
||||
setIsConfirming(true);
|
||||
}
|
||||
}}
|
||||
variant={props.button.confirm.variant ? props.button.confirm.variant : "default"}
|
||||
size={props.button.main.size ?? "default"}
|
||||
className={cn(props.button.main.className, "w-full")}
|
||||
>
|
||||
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
|
||||
{props.button.confirm.icon ? props.button.confirm.icon : null}
|
||||
<span>{props.button.confirm.text}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={props.button.cancel.variant ? props.button.cancel.variant : "default"}
|
||||
onClick={props.button.cancel.onClick ? () => props.button.cancel.onClick!() : () => setIsConfirming(false)}
|
||||
className={cn(props.button.main.className, "w-full")}
|
||||
size={props.button.main.size ?? "default"}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
import {Check, ChevronDown} from "lucide-react";
|
||||
|
||||
import {cn} from "@/lib/utils";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList} from "@/components/ui/command";
|
||||
@@ -110,7 +108,6 @@ export type comboBoxFormItemProps = comboBoxProps & {
|
||||
onChange: any;
|
||||
};
|
||||
|
||||
/** Combobox to use when working with zodForm. */
|
||||
export function ComboBoxFormItem(props: comboBoxFormItemProps) {
|
||||
const {values: choices, searchField = false, value, name, onChange} = props;
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function ConsoleSilencer() {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
for (const method of ["log", "debug", "info", "warn", "error"] as const) {
|
||||
console[method] = () => {};
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Link from "next/link";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {Plus} from "lucide-react";
|
||||
|
||||
type EmptyStatePlaceholderProps = {
|
||||
url: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
|
||||
export const EmptyStatePlaceholder = ({url, text}: EmptyStatePlaceholderProps) => {
|
||||
return (
|
||||
<Link
|
||||
href={url}
|
||||
className={cn(
|
||||
"flex 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-2"
|
||||
)}
|
||||
>
|
||||
<Plus className="w-5 h-5 lg:w-6 lg:h-6"/>
|
||||
<span className="text-sm lg:text-base font-medium">{text}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {Icon} from "@iconify/react";
|
||||
import {CircleHelp, KeyRound} from "lucide-react";
|
||||
|
||||
|
||||
export const providerSwitch = (provider: string) => {
|
||||
switch (provider) {
|
||||
case "google":
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Icon icon={"logos:google"} height="24" />
|
||||
</div>
|
||||
);
|
||||
case "credential":
|
||||
return (
|
||||
<div className="flex flex-row gap-x-2 items-center p-4">
|
||||
<KeyRound height="24" />
|
||||
<span>Email and Password</span>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="flex flex-row gap-x-2 items-center p-4">
|
||||
<CircleHelp height="24" />
|
||||
<span>No credentials</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,157 +1,3 @@
|
||||
// "use client";
|
||||
//
|
||||
// import {
|
||||
// ColumnDef,
|
||||
// flexRender,
|
||||
// getCoreRowModel,
|
||||
// useReactTable,
|
||||
// getPaginationRowModel,
|
||||
// getSortedRowModel,
|
||||
// SortingState,
|
||||
// ColumnFiltersState,
|
||||
// getFilteredRowModel,
|
||||
// } from "@tanstack/react-table";
|
||||
//
|
||||
// import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
// import { Input } from "@/components/ui/input";
|
||||
//
|
||||
// import { useState } from "react";
|
||||
// import { TablePagination } from "./table-pagination";
|
||||
// import { Checkbox } from "@/components/ui/checkbox";
|
||||
//
|
||||
// interface DataTableProps<TData, TValue> {
|
||||
// columns: ColumnDef<TData, TValue>[];
|
||||
// data: TData[];
|
||||
// enableFilter?: boolean;
|
||||
// enableSelect?: boolean;
|
||||
// enablePagination?: boolean;
|
||||
// paginationOptions?: {
|
||||
// pageSize: number[];
|
||||
// pageVisible: number;
|
||||
// className?: string;
|
||||
// };
|
||||
// filterOptions?: {
|
||||
// title?: string;
|
||||
// key: string;
|
||||
// };
|
||||
// emptyText?: string;
|
||||
// }
|
||||
//
|
||||
// export function DataTable<TData, TValue>({
|
||||
// columns,
|
||||
// data,
|
||||
// enableFilter = false,
|
||||
// enablePagination = true,
|
||||
// enableSelect = true,
|
||||
// paginationOptions = { pageSize: [10, 20, 30, 40, 50, 100], pageVisible: 3 },
|
||||
// filterOptions = { key: "id", title: "Filter by ID" },
|
||||
// emptyText = "No data.",
|
||||
// }: DataTableProps<TData, TValue>) {
|
||||
// const [sorting, setSorting] = useState<SortingState>([]);
|
||||
// const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
// const [rowSelection, setRowSelection] = useState({});
|
||||
//
|
||||
// if (enableSelect && data.length > 0) {
|
||||
// const selectColumnExists = columns.some((column) => column.id === "select");
|
||||
//
|
||||
// if (!selectColumnExists) {
|
||||
// columns.unshift({
|
||||
// id: "select",
|
||||
// header: ({ table }) => (
|
||||
// <Checkbox
|
||||
// checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
|
||||
// onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
// aria-label="Select all"
|
||||
// />
|
||||
// ),
|
||||
// cell: ({ row }) => <Checkbox checked={row.getIsSelected()} onCheckedChange={(value) => row.toggleSelected(!!value)} aria-label="Select row" />,
|
||||
// enableSorting: false,
|
||||
// enableHiding: false,
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// const table = useReactTable({
|
||||
// data,
|
||||
// columns,
|
||||
// getCoreRowModel: getCoreRowModel(),
|
||||
// getPaginationRowModel: getPaginationRowModel(),
|
||||
// onSortingChange: setSorting,
|
||||
// getSortedRowModel: getSortedRowModel(),
|
||||
// onColumnFiltersChange: setColumnFilters,
|
||||
// getFilteredRowModel: getFilteredRowModel(),
|
||||
// onRowSelectionChange: setRowSelection,
|
||||
// state: {
|
||||
// sorting,
|
||||
// columnFilters,
|
||||
// rowSelection,
|
||||
// },
|
||||
// });
|
||||
//
|
||||
// return (
|
||||
// <div className="h-full">
|
||||
// {enableFilter && (
|
||||
// <div className="flex items-center py-4">
|
||||
// <Input
|
||||
// placeholder={`${filterOptions.title ?? `Filter by ${filterOptions.key}`}`}
|
||||
// value={(table.getColumn(filterOptions.key)?.getFilterValue() as string) ?? ""}
|
||||
// onChange={(event) => table.getColumn(filterOptions.key)?.setFilterValue(event.target.value)}
|
||||
// className="max-w-sm"
|
||||
// />
|
||||
// </div>
|
||||
// )}
|
||||
// <div className="rounded-md border w-full">
|
||||
// <Table className="w-full">
|
||||
// <TableHeader>
|
||||
// {table.getHeaderGroups().map((headerGroup) => (
|
||||
// <TableRow key={headerGroup.id}>
|
||||
// {headerGroup.headers.map((header) => {
|
||||
// return (
|
||||
// <TableHead key={header.id}>
|
||||
// {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
// </TableHead>
|
||||
// );
|
||||
// })}
|
||||
// </TableRow>
|
||||
// ))}
|
||||
// </TableHeader>
|
||||
// <TableBody>
|
||||
// {table.getRowModel().rows?.length ? (
|
||||
// table.getRowModel().rows.map((row) => (
|
||||
// <TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
// {row.getVisibleCells().map((cell) => (
|
||||
// <TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||
// ))}
|
||||
// </TableRow>
|
||||
// ))
|
||||
// ) : (
|
||||
// <TableRow>
|
||||
// <TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
// {emptyText}
|
||||
// </TableCell>
|
||||
// </TableRow>
|
||||
// )}
|
||||
// </TableBody>
|
||||
// </Table>
|
||||
// </div>
|
||||
// <div className="flex items-center justify-end space-x-2 py-4 mt-6">
|
||||
// {enableSelect && (
|
||||
// <div className="flex-1 text-sm text-muted-foreground">
|
||||
// {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
// </div>
|
||||
// )}
|
||||
// {enablePagination && (
|
||||
// <TablePagination
|
||||
// table={table}
|
||||
// maxVisiblePages={paginationOptions?.pageVisible}
|
||||
// pageSizeOptions={paginationOptions.pageSize}
|
||||
// className={paginationOptions.className}
|
||||
// />
|
||||
// )}
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
"use client"
|
||||
import {
|
||||
ColumnDef,
|
||||
@@ -168,7 +14,7 @@ import {
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table";
|
||||
import {Input} from "@/components/ui/input";
|
||||
|
||||
import {useState} from "react";
|
||||
import {ReactNode, useEffect, useState} from "react";
|
||||
import {TablePagination} from "./table-pagination";
|
||||
import {Checkbox} from "@/components/ui/checkbox";
|
||||
import {Button} from "@/components/ui/button";
|
||||
@@ -195,6 +41,7 @@ interface DataTableProps<TData, TValue> {
|
||||
path: string;
|
||||
};
|
||||
highlightRow?: (row: TData) => boolean;
|
||||
selectedActions?: (rows: TData[]) => ReactNode;
|
||||
|
||||
}
|
||||
|
||||
@@ -207,12 +54,18 @@ export function DataTable<TData, TValue>({
|
||||
paginationOptions = {pageSize: [10, 20, 30, 40, 50, 100], pageVisible: 3},
|
||||
filterOptions = {key: "id", title: "Filter by ID"},
|
||||
emptyButton,
|
||||
highlightRow
|
||||
highlightRow,
|
||||
selectedActions,
|
||||
|
||||
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
setRowSelection({});
|
||||
}, [data]);
|
||||
|
||||
if (enableSelect && data.length > 0) {
|
||||
const selectColumnExists = columns.some((column) => column.id === "select");
|
||||
@@ -257,14 +110,20 @@ export function DataTable<TData, TValue>({
|
||||
<div
|
||||
className="flex flex-col h-full"
|
||||
>
|
||||
{enableFilter && (
|
||||
{enableFilter || selectedActions && (
|
||||
<div className="flex items-center py-4">
|
||||
<Input
|
||||
placeholder={`${filterOptions.title ?? `Filter by ${filterOptions.key}`}`}
|
||||
value={(table.getColumn(filterOptions.key)?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) => table.getColumn(filterOptions.key)?.setFilterValue(event.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
{enableFilter && (
|
||||
<Input
|
||||
placeholder={`${filterOptions.title ?? `Filter by ${filterOptions.key}`}`}
|
||||
value={(table.getColumn(filterOptions.key)?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) => table.getColumn(filterOptions.key)?.setFilterValue(event.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
)}
|
||||
{/*{selectedActions && table.getSelectedRowModel().rows.length > 0 && (*/}
|
||||
{selectedActions && (
|
||||
selectedActions(table.getSelectedRowModel().rows.map(row => row.original))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col justify-between h-full">
|
||||
@@ -277,6 +136,7 @@ export function DataTable<TData, TValue>({
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
@@ -288,7 +148,8 @@ export function DataTable<TData, TValue>({
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}
|
||||
className={highlightRow && highlightRow(row.original) ? "bg-gray-100 pointer-events-none" : ""}
|
||||
data-state={row.getIsSelected() && "selected"}>
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||
@@ -342,10 +203,7 @@ export function DataTable<TData, TValue>({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu";
|
||||
import {Toggle} from "@/components/ui/toggle";
|
||||
import {CheckIcon, Filter, RefreshCcw} from "lucide-react";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
|
||||
export type FilterItem = {
|
||||
label: string,
|
||||
value: string
|
||||
}
|
||||
|
||||
type FiltersDropdownProps = {
|
||||
items: FilterItem[];
|
||||
selectedItems: FilterItem[];
|
||||
onSelect: (item: FilterItem) => void;
|
||||
clearFilters: () => void;
|
||||
}
|
||||
|
||||
export const FiltersDropdown = ({items, selectedItems, onSelect, clearFilters}: FiltersDropdownProps) => {
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
<div className="relative">
|
||||
<Toggle variant="outline" size="sm" className="cursor-pointer w-fit">
|
||||
<Filter className="h-4 w-4"/>
|
||||
</Toggle>
|
||||
{selectedItems.length > 0 && (
|
||||
<Badge className="absolute -top-2 -right-2 h-4 w-4 rounded-full p-0 flex items-center justify-center text-[10px]">
|
||||
{selectedItems.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[150px]">
|
||||
{items.map((item, index) => {
|
||||
const isSelected = selectedItems.some(f => f.value === item.value);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={index}
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(item);
|
||||
}}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{isSelected && <CheckIcon className="h-4 w-4 text-blue-500"/>}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
<DropdownMenuItem
|
||||
disabled={selectedItems.length === 0}
|
||||
className="flex gap-2 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
clearFilters();
|
||||
}}
|
||||
>
|
||||
<RefreshCcw className='h-4 w-4'/>
|
||||
Clear filters
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -1,10 +1,10 @@
|
||||
"use server";
|
||||
import { userAction } from "@/safe-actions";
|
||||
import { z } from "zod";
|
||||
import { EmailFormSchema } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
|
||||
export const updateEmailSettingsAction = userAction
|
||||
.schema(
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import {Card, CardContent} from "@/components/ui/card";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useZodForm
|
||||
} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Form} from "@/components/ui/form";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
|
||||
import { EmailFormSchema, EmailFormType } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema";
|
||||
import { PasswordInput } from "@/components/wrappers/auth/password-input/password-input";
|
||||
import { updateEmailSettingsAction } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.action";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
EmailFormSchema,
|
||||
EmailFormType
|
||||
} from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema";
|
||||
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
||||
import {
|
||||
updateEmailSettingsAction
|
||||
} from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.action";
|
||||
import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
|
||||
export type EmailFormProps = {
|
||||
defaultValues?: EmailFormType;
|
||||
@@ -27,7 +40,7 @@ export const EmailForm = (props: EmailFormProps) => {
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: EmailFormType) => {
|
||||
const updateEmailSettings = await updateEmailSettingsAction({ name: "system", data: values });
|
||||
const updateEmailSettings = await updateEmailSettingsAction({name: "system", data: values});
|
||||
const data = updateEmailSettings?.data?.data;
|
||||
if (updateEmailSettings?.serverError || !data) {
|
||||
toast.error(updateEmailSettings?.serverError);
|
||||
@@ -53,14 +66,14 @@ export const EmailForm = (props: EmailFormProps) => {
|
||||
control={form.control}
|
||||
name="smtpFrom"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>From Email *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"exemple@portabase.com"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>{"The email from where the email will be send"}</FormDescription>
|
||||
<FormMessage />
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -68,14 +81,14 @@ export const EmailForm = (props: EmailFormProps) => {
|
||||
control={form.control}
|
||||
name="smtpHost"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server Host *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"ssl0.ovh.net"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>{"Your email server host"}</FormDescription>
|
||||
<FormMessage />
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -83,14 +96,14 @@ export const EmailForm = (props: EmailFormProps) => {
|
||||
control={form.control}
|
||||
name="smtpPort"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server Port *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"465"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>{"Your email server port (send)"}</FormDescription>
|
||||
<FormMessage />
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -99,7 +112,7 @@ export const EmailForm = (props: EmailFormProps) => {
|
||||
control={form.control}
|
||||
name="smtpPassword"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
@@ -107,7 +120,7 @@ export const EmailForm = (props: EmailFormProps) => {
|
||||
</FormControl>
|
||||
<FormDescription>{"Your email server password"}</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -115,14 +128,14 @@ export const EmailForm = (props: EmailFormProps) => {
|
||||
control={form.control}
|
||||
name="smtpUser"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>User Email *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"exemple@portabase.com"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>{"The email server user"}</FormDescription>
|
||||
<FormMessage />
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,8 @@ import { sendEmail } from "@/utils/email-helper";
|
||||
import TestEmailSettings from "../../../../../../emails/TestEmailSettings";
|
||||
import { render } from "@react-email/render";
|
||||
import { toast } from "sonner";
|
||||
import {Setting} from "@/db/schema/00_setting";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
import {EmailFormType} from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema";
|
||||
|
||||
export type SettingsEmailTabProps = {
|
||||
settings: Setting;
|
||||
@@ -53,7 +54,7 @@ export const SettingsEmailTab = (props: SettingsEmailTabProps) => {
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<EmailForm defaultValues={props.settings.smtpFrom ? props.settings : null} />
|
||||
<EmailForm defaultValues={props.settings.smtpFrom ? props.settings as EmailFormType : undefined } />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+32
-18
@@ -1,16 +1,19 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Info, ShieldCheck } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { StorageS3Form } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/storage-s3-form";
|
||||
import { useState } from "react";
|
||||
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { checkConnexionToS3 } from "@/features/upload/public/upload.action";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateStorageSettingsAction } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.action";
|
||||
import {Setting} from "@/db/schema/00_setting";
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
|
||||
import {Info, ShieldCheck} from "lucide-react";
|
||||
import {Switch} from "@/components/ui/switch";
|
||||
import {Label} from "@/components/ui/label";
|
||||
import {StorageS3Form} from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/storage-s3-form";
|
||||
import {useState} from "react";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {checkConnexionToS3} from "@/features/upload/public/upload.action";
|
||||
import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {
|
||||
updateStorageSettingsAction
|
||||
} from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.action";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
import {S3FormType} from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.schema";
|
||||
|
||||
export type SettingsStorageTabProps = {
|
||||
settings: Setting;
|
||||
@@ -33,7 +36,7 @@ export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
|
||||
const [isSwitched, setIsSwitched] = useState<boolean>(props.settings.storage !== "local");
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () => updateStorageSettingsAction({ name: "system", data: { storage: isSwitched ? "s3" : "local" } }),
|
||||
mutationFn: () => updateStorageSettingsAction({name: "system", data: {storage: isSwitched ? "s3" : "local"}}),
|
||||
onSuccess: () => {
|
||||
toast.success(`Settings updated successfully.`);
|
||||
router.refresh();
|
||||
@@ -48,14 +51,25 @@ export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
|
||||
await updateMutation.mutateAsync();
|
||||
};
|
||||
|
||||
const extractS3FormValues = (settings: Setting): S3FormType | undefined => {
|
||||
if (!settings.s3EndPointUrl) return undefined;
|
||||
return {
|
||||
s3EndPointUrl: settings.s3EndPointUrl,
|
||||
s3AccessKeyId: settings.s3AccessKeyId!,
|
||||
s3SecretAccessKey: settings.s3SecretAccessKey!,
|
||||
S3BucketName: settings.S3BucketName!,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full py-4">
|
||||
<h1>Settings for Portabase storage</h1>
|
||||
<Alert className="mt-3">
|
||||
<Info className="h-4 w-4" />
|
||||
<Info className="h-4 w-4"/>
|
||||
<AlertTitle>Informations</AlertTitle>
|
||||
<AlertDescription>
|
||||
Actually you can only store you data in one place : s3 compatible or in local. For exemple you cannot choose to store images in one place
|
||||
Actually you can only store you data in one place : s3 compatible or in local. For exemple you
|
||||
cannot choose to store images in one place
|
||||
and .dump files in another.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -79,14 +93,14 @@ export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
|
||||
onClick={async () => {
|
||||
await mutation.mutateAsync();
|
||||
}}
|
||||
icon={<ShieldCheck />}
|
||||
icon={<ShieldCheck/>}
|
||||
text="Test connexion"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isSwitched && (
|
||||
<div className="mt-5">
|
||||
<StorageS3Form defaultValues={props.settings.s3EndPointUrl ? props.settings : null} />
|
||||
<StorageS3Form defaultValues={extractS3FormValues(props.settings)}/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -1,11 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { userAction } from "@/safe-actions";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/db";
|
||||
import { S3FormSchema, StorageSwitchSchema } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
|
||||
export const updateS3SettingsAction = userAction
|
||||
.schema(
|
||||
|
||||
+2
-1
@@ -12,6 +12,7 @@ import { S3FormSchema, S3FormType } from "@/components/wrappers/dashboard/admin/
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { updateS3SettingsAction } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.action";
|
||||
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
||||
|
||||
export type S3FormProps = {
|
||||
defaultValues?: S3FormType;
|
||||
@@ -86,7 +87,7 @@ export const StorageS3Form = (props: S3FormProps) => {
|
||||
<FormItem>
|
||||
<FormLabel>Secret Key *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"The secret key token"} {...field} />
|
||||
<PasswordInput placeholder={"The secret key token"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>{"Add your secret key"}</FormDescription>
|
||||
<FormMessage />
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { SettingsEmailTab } from "@/components/wrappers/dashboard/admin/admin-email-tab/settings-email-tab";
|
||||
import { SettingsStorageTab } from "@/components/wrappers/dashboard/admin/admin-storage-tab/settings-storage-tab";
|
||||
import { AdminUsersTable } from "@/components/wrappers/dashboard/admin/admin-user-table";
|
||||
import { User } from "@/db/schema/01_user";
|
||||
import { Setting } from "@/db/schema/00_setting";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
||||
import {SettingsEmailTab} from "@/components/wrappers/dashboard/admin/admin-email-tab/settings-email-tab";
|
||||
import {SettingsStorageTab} from "@/components/wrappers/dashboard/admin/admin-storage-tab/settings-storage-tab";
|
||||
import {User, UserWithAccounts} from "@/db/schema/02_user";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
import {useEffect, useState} from "react";
|
||||
import {useRouter, useSearchParams} from "next/navigation";
|
||||
import {AdminUsersTable} from "@/components/wrappers/dashboard/admin/admin-user-tab/admin-user-table";
|
||||
|
||||
export type AdminTabsProps = {
|
||||
users: User[];
|
||||
users: UserWithAccounts[];
|
||||
settings: Setting;
|
||||
};
|
||||
|
||||
export const AdminTabs = ({ users, settings }: AdminTabsProps) => {
|
||||
export const AdminTabs = ({users, settings}: AdminTabsProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -44,13 +44,13 @@ export const AdminTabs = ({ users, settings }: AdminTabsProps) => {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="users">
|
||||
<AdminUsersTable users={users} />
|
||||
<AdminUsersTable users={users}/>
|
||||
</TabsContent>
|
||||
<TabsContent value="email">
|
||||
<SettingsEmailTab settings={settings} />
|
||||
<SettingsEmailTab settings={settings}/>
|
||||
</TabsContent>
|
||||
<TabsContent value="storage">
|
||||
<SettingsStorageTab settings={settings} />
|
||||
<SettingsStorageTab settings={settings}/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {ColumnDef} from "@tanstack/react-table";
|
||||
import {Unlink} from "lucide-react";
|
||||
import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {unlinkUserProviderAction} from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
|
||||
import {providerSwitch} from "@/components/wrappers/common/provider-switch";
|
||||
|
||||
export const accountsColumns: ColumnDef<{
|
||||
id: string;
|
||||
provider: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
accountId: string;
|
||||
scopes: string[];
|
||||
}>[] = [
|
||||
{
|
||||
id: "provider",
|
||||
header: "Provider",
|
||||
cell: ({row}) => {
|
||||
return (
|
||||
<div>
|
||||
{providerSwitch(row.original.provider)}
|
||||
</div>
|
||||
|
||||
)
|
||||
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Action",
|
||||
id: "actions",
|
||||
cell: ({row, table}) => {
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (row.original.provider === "credential") {
|
||||
toast.error(`This provider cannot be unlinked.`);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
if (table.getRowModel().rows.length <= 1) {
|
||||
toast.error(`You only have one provider linked to your account. Please add more one to unlink this`);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await unlinkUserProviderAction({
|
||||
provider: row.original.provider,
|
||||
account: row.original.accountId,
|
||||
});
|
||||
|
||||
if (status?.serverError || !status) {
|
||||
toast.error(status?.serverError);
|
||||
return;
|
||||
}
|
||||
toast.success(`Provider unlinked successfully.`);
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonWithLoading
|
||||
variant="outline"
|
||||
text=""
|
||||
disabled={row.original.provider === "credential" || table.getRowModel().rows.length <= 1}
|
||||
icon={<Unlink color="red" size={15}/>}
|
||||
onClick={async () => {
|
||||
await mutation.mutateAsync();
|
||||
}}
|
||||
size="icon"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,26 @@
|
||||
import {User, UserWithAccounts} from "@/db/schema/02_user";
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
|
||||
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
||||
import {usersColumnsAdmin} from "@/components/wrappers/dashboard/admin/admin-user-tab/columns-users";
|
||||
|
||||
export type AdminUsersTableProps = {
|
||||
users: UserWithAccounts[];
|
||||
|
||||
};
|
||||
|
||||
export const AdminUsersTable = (props: AdminUsersTableProps) => {
|
||||
const {users} = props;
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4 h-full py-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active users</CardTitle>
|
||||
<CardDescription>Manage your users</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable columns={usersColumnsAdmin} data={users}/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+32
-19
@@ -9,22 +9,20 @@ import {useState} from "react";
|
||||
import {Trash2} from "lucide-react";
|
||||
import {deleteUserAction} from "@/components/wrappers/dashboard/profile/button-delete-account/delete-account.action";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {User} from "@/db/schema/01_user";
|
||||
import {UserWithAccounts} from "@/db/schema/02_user";
|
||||
import {authClient, useSession} from "@/lib/auth/auth-client";
|
||||
import {formatFrenchDate} from "@/utils/date-formatting";
|
||||
import {providerSwitch} from "@/components/wrappers/common/provider-switch";
|
||||
|
||||
export const usersColumnsAdmin: ColumnDef<User>[] = [
|
||||
export const usersColumnsAdmin: ColumnDef<UserWithAccounts>[] = [
|
||||
{
|
||||
accessorKey: "role",
|
||||
header: "Role",
|
||||
cell: ({row}) => {
|
||||
const [role, setRole] = useState<string>(row.getValue("role"));
|
||||
|
||||
|
||||
const {data: session, isPending, error} = authClient.useSession();
|
||||
|
||||
|
||||
|
||||
const isSuperAdmin = session?.user.role == "superadmin";
|
||||
const isCurrentUser = session?.user.email === row.original.email;
|
||||
|
||||
const updateMutation = useMutation({
|
||||
@@ -51,11 +49,10 @@ export const usersColumnsAdmin: ColumnDef<User>[] = [
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={isCurrentUser ? "cursor-not-allowed opacity-50" : "cursor-pointer"}
|
||||
onClick={isCurrentUser ? undefined : () => handleUpdateRole()}
|
||||
className={isCurrentUser || !isSuperAdmin ? "cursor-not-allowed opacity-50" : "cursor-pointer"}
|
||||
onClick={isCurrentUser || !isSuperAdmin ? undefined : () => handleUpdateRole()}
|
||||
variant="outline"
|
||||
>
|
||||
{role}
|
||||
@@ -71,6 +68,21 @@ export const usersColumnsAdmin: ColumnDef<User>[] = [
|
||||
accessorKey: "email",
|
||||
header: "Email",
|
||||
},
|
||||
{
|
||||
accessorKey: "accounts",
|
||||
header: "Provider ID",
|
||||
cell: ({row}) => {
|
||||
return(
|
||||
<div>
|
||||
{row.original.accounts.map((item) => (
|
||||
<div key={item.id}>
|
||||
{providerSwitch(item.providerId)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "updatedAt",
|
||||
header: "Updated At",
|
||||
@@ -84,6 +96,7 @@ export const usersColumnsAdmin: ColumnDef<User>[] = [
|
||||
cell: ({row}) => {
|
||||
const router = useRouter();
|
||||
const {data: session, isPending} = useSession();
|
||||
const isSuperAdmin = session?.user.role == "superadmin";
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => deleteUserAction(row.original.id),
|
||||
@@ -95,16 +108,16 @@ export const usersColumnsAdmin: ColumnDef<User>[] = [
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonWithLoading
|
||||
disabled={!session || session?.user.email === row.original.email}
|
||||
variant="outline"
|
||||
text=""
|
||||
icon={<Trash2 color="red" size={15}/>}
|
||||
onClick={async () => {
|
||||
await mutation.mutateAsync();
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
<ButtonWithLoading
|
||||
disabled={!isSuperAdmin || !session || session?.user.email === row.original.email}
|
||||
variant="outline"
|
||||
text=""
|
||||
icon={<Trash2 color="red" size={15}/>}
|
||||
onClick={async () => {
|
||||
await mutation.mutateAsync();
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -0,0 +1,87 @@
|
||||
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Session } from "better-auth";
|
||||
import { Unlink } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import detectOSWithUA from "@/utils/os-parser";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { timeAgo } from "@/utils/date-formatting";
|
||||
import {deleteUserSessionAction} from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
|
||||
|
||||
export const sessionsColumns: ColumnDef<Session>[] = [
|
||||
{
|
||||
accessorKey: "expiresAt",
|
||||
header: "Expires At",
|
||||
cell: ({ row }) => {
|
||||
return timeAgo(row.original.expiresAt);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "ipAddress",
|
||||
header: "IP Address",
|
||||
},
|
||||
{
|
||||
id: "device",
|
||||
header: "Device",
|
||||
cell: ({ row }) => {
|
||||
const os = detectOSWithUA(row.original.userAgent!);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-x-2 items-center pt-4 pb-4">
|
||||
{os.icon && <Icon icon={`logos:${os.icon.name}`} height={os.icon.size.height} width={os.icon.size.width} />}
|
||||
{os.showText && <span>{os.name}</span>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "userAgent",
|
||||
header: "User Agent",
|
||||
},
|
||||
{
|
||||
header: "Action",
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (session?.session.id === row.original.id) {
|
||||
toast.error(`Unable to unlink active session.`);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await deleteUserSessionAction(row.original.token);
|
||||
|
||||
if (status?.serverError || !status) {
|
||||
toast.error(status?.serverError);
|
||||
return;
|
||||
}
|
||||
toast.success(`Session deleted successfully.`);
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonWithLoading
|
||||
variant="outline"
|
||||
disabled={session?.session.id === row.original.id}
|
||||
text=""
|
||||
icon={<Unlink color="red" size={15} />}
|
||||
onClick={async () => {
|
||||
await mutation.mutateAsync();
|
||||
}}
|
||||
size="icon"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,21 +0,0 @@
|
||||
import { usersColumnsAdmin } from "@/components/wrappers/dashboard/admin/columns-users";
|
||||
import { User } from "@/db/schema/01_user";
|
||||
import { DataTable } from "../../common/table/data-table";
|
||||
|
||||
export type AdminUsersTableProps = {
|
||||
users: User[];
|
||||
};
|
||||
|
||||
export const AdminUsersTable = (props: AdminUsersTableProps) => {
|
||||
const { users } = props;
|
||||
return (
|
||||
<div className="flex flex-col h-full py-4">
|
||||
<div className="flex gap-4 h-fit justify-between">
|
||||
<h1>List of Portabase's users</h1>
|
||||
</div>
|
||||
<div className="mt-5 h-full">
|
||||
<DataTable columns={usersColumnsAdmin} data={users} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import {getServerUrl} from "@/utils/get-server-url";
|
||||
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
||||
import {useState} from "react";
|
||||
import {CopyButton} from "@/components/wrappers/common/button/copy-button";
|
||||
import {Agent} from "@/db/schema/07_agent";
|
||||
import {Agent} from "@/db/schema/08_agent";
|
||||
|
||||
export type AgentCardKeyProps = {
|
||||
agent: Agent;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import { formatDateLastContact } from "@/utils/date-formatting";
|
||||
import { ConnectionCircle } from "@/components/wrappers/common/connection-circle";
|
||||
import {Agent} from "@/db/schema/07_agent";
|
||||
import {Agent} from "@/db/schema/08_agent";
|
||||
|
||||
export type agentCardProps = {
|
||||
data: Agent;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use server";
|
||||
import {ActionError, userAction} from "@/safe-actions";
|
||||
import {ActionError, userAction} from "@/lib/safe-actions/actions";
|
||||
import {AgentSchema} from "@/components/wrappers/dashboard/agent/agent-form/agent-form.schema";
|
||||
import {z} from "zod";
|
||||
import {eq, and, ne, count} from "drizzle-orm";
|
||||
|
||||
@@ -8,7 +8,7 @@ import { PropsWithChildren } from "react";
|
||||
import { CopyButton } from "@/components/wrappers/common/button/copy-button";
|
||||
import { getServerUrl } from "@/utils/get-server-url";
|
||||
import { CodeSnippet } from "@/components/wrappers/code-snippet/code-snippet";
|
||||
import {Agent} from "@/db/schema/07_agent";
|
||||
import {Agent} from "@/db/schema/08_agent";
|
||||
|
||||
export type agentRegistrationDialogProps = PropsWithChildren<{
|
||||
agent: Agent;
|
||||
|
||||
+28
-11
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import {Trash2} from "lucide-react";
|
||||
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {toast} from "sonner";
|
||||
import {deleteAgentAction} from "@/components/wrappers/dashboard/agent/button-delete-agent/delete-agent.action";
|
||||
|
||||
export type ButtonDeleteAgentProps = {
|
||||
@@ -29,14 +29,31 @@ export const ButtonDeleteAgent = (props: ButtonDeleteAgentProps) => {
|
||||
|
||||
return (
|
||||
<ButtonWithConfirm
|
||||
text={props.text ? props.text : ""}
|
||||
onClick={() => {
|
||||
mutation.mutate();
|
||||
title={props.text ? props.text : ""}
|
||||
description="Are you sure you want to remove this agent? This action cannot be undone."
|
||||
button={{
|
||||
main: {
|
||||
text: props.text ? props.text : "",
|
||||
variant: "outline",
|
||||
icon: <Trash2 color="red"/>,
|
||||
},
|
||||
confirm: {
|
||||
className: "w-full",
|
||||
text: "Delete",
|
||||
icon: <Trash2/>,
|
||||
variant: "destructive",
|
||||
onClick: () => {
|
||||
mutation.mutate();
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
className: "w-full",
|
||||
text: "Cancel",
|
||||
icon: <Trash2/>,
|
||||
variant: "outline",
|
||||
},
|
||||
}}
|
||||
variant={"destructive"}
|
||||
isPending={mutation.isPending}
|
||||
className="gap-2"
|
||||
icon={<Trash2 />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import {userAction} from "@/safe-actions";
|
||||
import {z} from "zod";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {Agent} from "@/db/schema/07_agent";
|
||||
import {Agent} from "@/db/schema/08_agent";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
|
||||
export const deleteAgentAction = userAction.schema(z.string()).action(async ({parsedInput}): Promise<ServerActionResult<Agent>> => {
|
||||
try {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { userAction } from "@/safe-actions";
|
||||
import { db } from "@/db";
|
||||
import { ServerActionResult } from "@/types/action-type";
|
||||
import {z} from "zod";
|
||||
import {db} from "@/db";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {Backup} from "@/db/schema/06_database";
|
||||
import {Backup} from "@/db/schema/07_database";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
|
||||
export const backupButtonAction = userAction.schema(z.string()).action(async ({ parsedInput }): Promise<ServerActionResult<Backup>> => {
|
||||
export const backupButtonAction = userAction.schema(z.string()).action(async ({parsedInput}): Promise<ServerActionResult<Backup>> => {
|
||||
try {
|
||||
const [createdBackup] = await db
|
||||
.insert(drizzleDb.schemas.backup)
|
||||
@@ -22,7 +23,7 @@ export const backupButtonAction = userAction.schema(z.string()).action(async ({
|
||||
value: createdBackup,
|
||||
actionSuccess: {
|
||||
message: "Backup has been successfully created.",
|
||||
messageParams: { databaseId: parsedInput },
|
||||
messageParams: {databaseId: parsedInput},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -34,7 +35,7 @@ export const backupButtonAction = userAction.schema(z.string()).action(async ({
|
||||
message: "Failed to create backup.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: { databaseId: parsedInput },
|
||||
messageParams: {databaseId: parsedInput},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import {useRouter} from "next/navigation";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
|
||||
import { backupButtonAction } from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
|
||||
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {backupButtonAction} from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {Database, DatabaseZap} from "lucide-react";
|
||||
import {useIsMobile} from "@/hooks/use-mobile";
|
||||
|
||||
export type BackupButtonProps = {
|
||||
databaseId: string;
|
||||
@@ -15,6 +17,8 @@ export type BackupButtonProps = {
|
||||
|
||||
export const BackupButton = (props: BackupButtonProps) => {
|
||||
const router = useRouter();
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (databaseId: string) => {
|
||||
const backup = await backupButtonAction(databaseId);
|
||||
@@ -32,8 +36,9 @@ export const BackupButton = (props: BackupButtonProps) => {
|
||||
|
||||
return (
|
||||
<ButtonWithLoading
|
||||
icon={<DatabaseZap/>}
|
||||
disabled={props.disable}
|
||||
text="Backup"
|
||||
text={isMobile ? "" : "Backup"}
|
||||
isPending={mutation.isPending}
|
||||
size={"default"}
|
||||
onClick={async () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { redirect } from "next/navigation";
|
||||
import { CircleUser, LogOut, ShieldHalf } from "lucide-react";
|
||||
import { signOut } from "@/lib/auth/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { User } from "@/db/schema/01_user";
|
||||
import { User } from "@/db/schema/02_user";
|
||||
|
||||
export type LoggedInDropdownProps = PropsWithChildren<{
|
||||
user: User;
|
||||
|
||||
@@ -22,9 +22,11 @@ export const SidebarMenuCustomMain = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groupContentApplication: SidebarGroupItem["group_content"] = [
|
||||
{ title: "Dashboard", url: "/home", icon: Home },
|
||||
];
|
||||
|
||||
const groupContent: SidebarGroupItem["group_content"] = [
|
||||
{ title: "Dashboard", url: "/home", icon: Home },
|
||||
{ title: "Projects", url: "/projects", icon: Layers, details:true },
|
||||
{ title: "Statistics", url: "/statistics", icon: ChartArea },
|
||||
];
|
||||
@@ -37,6 +39,11 @@ export const SidebarMenuCustomMain = () => {
|
||||
{
|
||||
label: "Application",
|
||||
type: "list",
|
||||
group_content: groupContentApplication,
|
||||
},
|
||||
{
|
||||
label: "Organization",
|
||||
type: "list",
|
||||
group_content: groupContent,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateDatabaseBackupPolicyAction } from "@/components/wrappers/dashboard/database/cron-button/cron.action";
|
||||
import {Database} from "@/db/schema/06_database";
|
||||
import {Database} from "@/db/schema/07_database";
|
||||
|
||||
export type CronButtonProps = {
|
||||
database: Database;
|
||||
@@ -20,9 +20,11 @@ export type CronButtonProps = {
|
||||
export const CronButton = (props: CronButtonProps) => {
|
||||
const router = useRouter();
|
||||
const [isSwitched, setIsSwitched] = useState(props.database.backupPolicy !== null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const updateDatabaseBackupPolicy = useMutation({
|
||||
mutationFn: (value: string) => updateDatabaseBackupPolicyAction({ databaseId: props.database.id, backupPolicy: value }),
|
||||
mutationFn: (value: string) =>
|
||||
updateDatabaseBackupPolicyAction({ databaseId: props.database.id, backupPolicy: value }),
|
||||
onSuccess: () => {
|
||||
toast.success(`Method updated successfully.`);
|
||||
router.refresh();
|
||||
@@ -34,15 +36,15 @@ export const CronButton = (props: CronButtonProps) => {
|
||||
|
||||
const handleTypeChange = async (state: boolean) => {
|
||||
setIsSwitched(state);
|
||||
if (state == false) {
|
||||
if (!state) {
|
||||
await updateDatabaseBackupPolicy.mutateAsync("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" {...props}>
|
||||
<Button variant="outline" {...props} onClick={() => setOpen(true)}>
|
||||
<Clock9 />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -64,7 +66,14 @@ export const CronButton = (props: CronButtonProps) => {
|
||||
id="type-mode"
|
||||
/>
|
||||
</div>
|
||||
{isSwitched ? <CronInput database={props.database} /> : null}
|
||||
{isSwitched ? (
|
||||
<CronInput
|
||||
database={props.database}
|
||||
onSuccess={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { AdvancedCronSelect } from "./advanced-cron-select";
|
||||
import { updateDatabaseBackupPolicyAction } from "@/components/wrappers/dashboard/database/cron-button/cron.action";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {Database} from "@/db/schema/06_database";
|
||||
import {AdvancedCronSelect} from "./advanced-cron-select";
|
||||
import {updateDatabaseBackupPolicyAction} from "@/components/wrappers/dashboard/database/cron-button/cron.action";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {useState} from "react";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {toast} from "sonner";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {Database} from "@/db/schema/07_database";
|
||||
|
||||
export type CronInputProps = {
|
||||
database: Database;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export const CronInput = ({ database }: CronInputProps) => {
|
||||
export const CronInput = ({database, onSuccess}: CronInputProps) => {
|
||||
const [cron, setCron] = useState<string>(database.backupPolicy ?? "* * * * *");
|
||||
const router = useRouter();
|
||||
|
||||
const updateBackupPolicy = useMutation({
|
||||
mutationFn: (value: string) => updateDatabaseBackupPolicyAction({ databaseId: database.id, backupPolicy: value }),
|
||||
mutationFn: (value: string) => updateDatabaseBackupPolicyAction({databaseId: database.id, backupPolicy: value}),
|
||||
onSuccess: () => {
|
||||
toast.success(`Cron updated successfully.`);
|
||||
onSuccess?.()
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
@@ -29,7 +31,7 @@ export const CronInput = ({ database }: CronInputProps) => {
|
||||
|
||||
const handleChangeCron = (type: "minute" | "hour" | "day-of-month" | "month" | "day-of-week", value: string) => {
|
||||
const cronParts = cron.split(" ");
|
||||
const indexMap = { minute: 0, hour: 1, "day-of-month": 2, month: 3, "day-of-week": 4 };
|
||||
const indexMap = {minute: 0, hour: 1, "day-of-month": 2, month: 3, "day-of-week": 4};
|
||||
cronParts[indexMap[type]] = value;
|
||||
setCron(cronParts.join(" "));
|
||||
};
|
||||
@@ -44,7 +46,7 @@ export const CronInput = ({ database }: CronInputProps) => {
|
||||
<AdvancedCronSelect
|
||||
id="minute"
|
||||
label="Minute"
|
||||
options={Array.from({ length: 60 }, (_, i) => String(i).padStart(2, "0"))}
|
||||
options={Array.from({length: 60}, (_, i) => String(i).padStart(2, "0"))}
|
||||
type="minute"
|
||||
value={cron.split(" ")[0]}
|
||||
defaultValue={cron.split(" ")[0]}
|
||||
@@ -53,7 +55,7 @@ export const CronInput = ({ database }: CronInputProps) => {
|
||||
<AdvancedCronSelect
|
||||
id="hour"
|
||||
label="Hour"
|
||||
options={Array.from({ length: 24 }, (_, i) => String(i).padStart(2, "0"))}
|
||||
options={Array.from({length: 24}, (_, i) => String(i).padStart(2, "0"))}
|
||||
type="hour"
|
||||
value={cron.split(" ")[1]}
|
||||
defaultValue={cron.split(" ")[1]}
|
||||
@@ -62,7 +64,7 @@ export const CronInput = ({ database }: CronInputProps) => {
|
||||
<AdvancedCronSelect
|
||||
id="day-of-month"
|
||||
label="Day of Month"
|
||||
options={Array.from({ length: 31 }, (_, i) => String(i + 1).padStart(2, "0"))}
|
||||
options={Array.from({length: 31}, (_, i) => String(i + 1).padStart(2, "0"))}
|
||||
type="day-of-month"
|
||||
value={cron.split(" ")[2]}
|
||||
defaultValue={cron.split(" ")[2]}
|
||||
@@ -86,13 +88,14 @@ export const CronInput = ({ database }: CronInputProps) => {
|
||||
defaultValue={cron.split(" ")[4]}
|
||||
onValueChange={(value) => handleChangeCron("day-of-week", value)}
|
||||
/>
|
||||
<Separator />
|
||||
<Separator/>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-semibold">Cron Expression</div>
|
||||
<div className="font-mono text-muted-foreground">{cron}</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">This cron expression determines when the job will run.</div>
|
||||
<div className="text-sm text-muted-foreground">This cron expression determines when the job will run.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<Button
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { userAction } from "@/safe-actions";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {z} from "zod";
|
||||
import {db} from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
|
||||
export const updateDatabaseBackupPolicyAction = userAction
|
||||
.schema(
|
||||
@@ -13,7 +13,7 @@ export const updateDatabaseBackupPolicyAction = userAction
|
||||
backupPolicy: z.string(),
|
||||
})
|
||||
)
|
||||
.action(async ({ parsedInput }) => {
|
||||
.action(async ({parsedInput}) => {
|
||||
const cronPolicy = parsedInput.backupPolicy === "" ? null : parsedInput.backupPolicy;
|
||||
|
||||
const [updated] = await db
|
||||
@@ -25,6 +25,11 @@ export const updateDatabaseBackupPolicyAction = userAction
|
||||
.returning()
|
||||
.execute();
|
||||
|
||||
if (cronPolicy == null) {
|
||||
await db.delete(drizzleDb.schemas.retentionPolicy)
|
||||
.where(eq(drizzleDb.schemas.retentionPolicy.databaseId, parsedInput.databaseId)).execute();
|
||||
}
|
||||
|
||||
return {
|
||||
data: updated,
|
||||
};
|
||||
|
||||
@@ -58,7 +58,6 @@ export const DatabaseForm = (props: DatabaseFormProps) => {
|
||||
form={form}
|
||||
className="flex flex-col gap-4 mt-3"
|
||||
onSubmit={async (values) => {
|
||||
console.log("sssssss");
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { userAction } from "@/safe-actions";
|
||||
import { db } from "@/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { DatabaseSchema } from "@/components/wrappers/dashboard/database/database-form/form-database.schema";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {Database} from "@/db/schema/07_database";
|
||||
|
||||
export const updateDatabaseAction = userAction
|
||||
.schema(
|
||||
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
"use client"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useZodForm
|
||||
} from "@/components/ui/form";
|
||||
import {RetentionSettings, RetentionSettingsSchema} from "./backup-retention-settings.schema";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {updateOrCreateBackupRetentionPolicyAction} from "./backup-retention-settings.action";
|
||||
import {DatabaseWith, RetentionPolicy} from "@/db/schema/07_database";
|
||||
import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {RadioGroup, RadioGroupItem} from "@/components/ui/radio-group";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Calendar, Save} from "lucide-react";
|
||||
|
||||
export type BackupRetentionSettingsFormProps = {
|
||||
defaultValues?: RetentionPolicy;
|
||||
database: DatabaseWith;
|
||||
};
|
||||
|
||||
export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRetentionSettingsFormProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const defaultValuesFormatted: RetentionSettings = {
|
||||
type: defaultValues?.type,
|
||||
count: defaultValues?.count ?? 7,
|
||||
days: defaultValues?.days ?? 30,
|
||||
gfs: {
|
||||
daily: defaultValues?.gfsDaily ?? 7,
|
||||
weekly: defaultValues?.gfsWeekly ?? 4,
|
||||
monthly: defaultValues?.gfsMonthly ?? 12,
|
||||
yearly: defaultValues?.gfsYearly ?? 3,
|
||||
},
|
||||
};
|
||||
|
||||
const form = useZodForm({
|
||||
schema: RetentionSettingsSchema,
|
||||
defaultValues: defaultValuesFormatted,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (payload: RetentionSettings) =>
|
||||
await updateOrCreateBackupRetentionPolicyAction({
|
||||
databaseId: database.id,
|
||||
settings: payload,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success("Retention policy updated successfully.");
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("An error occurred while updating retention policy.");
|
||||
},
|
||||
});
|
||||
|
||||
const calculateTotalFiles = (values: RetentionSettings) => {
|
||||
if (values.type === "gfs" && values.gfs) {
|
||||
return (
|
||||
(values.gfs.daily ?? 0) +
|
||||
(values.gfs.weekly ?? 0) +
|
||||
(values.gfs.monthly ?? 0) +
|
||||
(values.gfs.yearly ?? 0)
|
||||
);
|
||||
}
|
||||
return values.type === "count" ? values.count ?? 0 : values.type === "days" ? values.days ?? 0 : 0;
|
||||
};
|
||||
|
||||
const getStorageEstimate = (totalFiles: number) => {
|
||||
if (totalFiles <= 10) return "Low";
|
||||
if (totalFiles <= 30) return "Medium";
|
||||
return "High";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 py-0">
|
||||
<div className="px-3">
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-6 mt-0"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Retention Policy Type</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
value={field.value ?? ""}
|
||||
onValueChange={field.onChange}
|
||||
className="grid grid-cols-1 gap-4"
|
||||
>
|
||||
{[
|
||||
{
|
||||
id: "count",
|
||||
label: "Keep last N backups",
|
||||
desc: "Simple count-based retention (e.g., keep last 10 backups)",
|
||||
},
|
||||
{
|
||||
id: "days",
|
||||
label: "Keep backups for X days",
|
||||
desc: "Time-based retention (e.g., keep backups for 30 days)",
|
||||
},
|
||||
{
|
||||
id: "gfs",
|
||||
label: "GFS Rotation",
|
||||
desc: "Grandfather-Father-Son rotation for enterprise/critical systems",
|
||||
badge: "Recommended",
|
||||
},
|
||||
].map((opt) => (
|
||||
<FormLabel
|
||||
key={opt.id}
|
||||
htmlFor={opt.id}
|
||||
className={`flex items-center space-x-3 rounded-lg border p-4 transition-colors cursor-pointer ${
|
||||
field.value === opt.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<RadioGroupItem value={opt.id} id={opt.id}/>
|
||||
<div className="flex-1">
|
||||
<span className="font-medium flex items-center gap-2">
|
||||
{opt.label}
|
||||
{opt.badge && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{opt.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">{opt.desc}</p>
|
||||
</div>
|
||||
|
||||
{database.retentionPolicy?.type === opt.id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Actual
|
||||
</Badge>
|
||||
)}
|
||||
</FormLabel>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("type") && (
|
||||
<Separator/>
|
||||
)}
|
||||
|
||||
|
||||
{form.watch("type") === "count" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="count"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Number of backups to keep</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
className="w-32"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Older backups beyond this count will be automatically deleted.
|
||||
</FormDescription>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{form.watch("type") === "days" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="days"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Retention period (days)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={3650}
|
||||
className="w-32"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Backups older than {field.value} days will be automatically deleted.
|
||||
</FormDescription>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{form.watch("type") === "gfs" && (
|
||||
<div className="space-y-4">
|
||||
{["daily", "weekly", "monthly", "yearly"].map((key) => (
|
||||
<FormField
|
||||
key={key}
|
||||
control={form.control}
|
||||
name={`gfs.${key}` as const}
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)} backups
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={key === "yearly" ? 50 : key === "monthly" ? 120 : key === "weekly" ? 52 : 31}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Keep N {key} backups
|
||||
</FormDescription>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.watch("type") && (
|
||||
<>
|
||||
<Separator/>
|
||||
<div className="rounded-lg border p-4 space-y-3 bg-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground"/>
|
||||
<span className="font-medium">Storage Impact Summary</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const totalFiles = calculateTotalFiles(form.getValues());
|
||||
const estimate = getStorageEstimate(totalFiles);
|
||||
return (
|
||||
<Badge
|
||||
variant={
|
||||
estimate === "Low"
|
||||
? "default"
|
||||
: estimate === "Medium"
|
||||
? "secondary"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{estimate} Usage
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Estimated files per database:</span>
|
||||
<p className="font-medium">
|
||||
{calculateTotalFiles(form.getValues())} backup files
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Policy type:</span>
|
||||
<p className="font-medium capitalize">
|
||||
{form.watch("type") === "gfs"
|
||||
? "GFS Rotation"
|
||||
: form.watch("type") === "count"
|
||||
? "Count-based"
|
||||
: "Time-based"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={mutation.isPending} className="w-full">
|
||||
<Save className="h-4 w-4 mr-2"/>
|
||||
{mutation.isPending ? "Saving Policy..." : "Save Retention Policy"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
"use server"
|
||||
|
||||
import {z} from "zod"
|
||||
import {db} from "@/db"
|
||||
import {eq} from "drizzle-orm"
|
||||
import * as drizzleDb from "@/db";
|
||||
import {
|
||||
RetentionSettingsSchema
|
||||
} from "@/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.schema";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
|
||||
|
||||
export const updateOrCreateBackupRetentionPolicyAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
databaseId: z.string(),
|
||||
settings: RetentionSettingsSchema,
|
||||
})
|
||||
)
|
||||
.action(async ({parsedInput}) => {
|
||||
const {databaseId, settings} = parsedInput
|
||||
|
||||
// Check if a retention policy already exists
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(drizzleDb.schemas.retentionPolicy)
|
||||
.where(eq(drizzleDb.schemas.retentionPolicy.databaseId, databaseId))
|
||||
.limit(1)
|
||||
|
||||
let updated
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Update existing policy
|
||||
updated = await db
|
||||
.update(drizzleDb.schemas.retentionPolicy)
|
||||
.set({
|
||||
type: settings.type,
|
||||
count: settings.count,
|
||||
days: settings.days,
|
||||
gfsDaily: settings.gfs.daily,
|
||||
gfsWeekly: settings.gfs.weekly,
|
||||
gfsMonthly: settings.gfs.monthly,
|
||||
gfsYearly: settings.gfs.yearly,
|
||||
})
|
||||
.where(eq(drizzleDb.schemas.retentionPolicy.databaseId, databaseId))
|
||||
.returning()
|
||||
} else {
|
||||
// Insert new policy
|
||||
|
||||
updated = await db
|
||||
.insert(drizzleDb.schemas.retentionPolicy)
|
||||
.values({
|
||||
databaseId,
|
||||
type: settings.type ?? "gfs",
|
||||
count: settings.count,
|
||||
days: settings.days,
|
||||
gfsDaily: settings.gfs.daily,
|
||||
gfsWeekly: settings.gfs.weekly,
|
||||
gfsMonthly: settings.gfs.monthly,
|
||||
gfsYearly: settings.gfs.yearly,
|
||||
})
|
||||
.returning()
|
||||
}
|
||||
return {
|
||||
data: updated[0],
|
||||
}
|
||||
})
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import {z} from "zod";
|
||||
|
||||
const GFSSettingsSchema = z.object({
|
||||
daily: z.number().min(1).max(31),
|
||||
weekly: z.number().min(0).max(52),
|
||||
monthly: z.number().min(0).max(120),
|
||||
yearly: z.number().min(0).max(50),
|
||||
});
|
||||
|
||||
export const RetentionSettingsSchema = z.object({
|
||||
type: z.enum(["count", "days", "gfs"]).optional(),
|
||||
count: z.number().min(1).max(100),
|
||||
days: z.number().min(1).max(3650),
|
||||
gfs: GFSSettingsSchema,
|
||||
});
|
||||
|
||||
export type RetentionSettings = z.infer<typeof RetentionSettingsSchema>;
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
"use client"
|
||||
|
||||
import {useState} from "react"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Separator} from "@/components/ui/separator"
|
||||
import {RadioGroup, RadioGroupItem} from "@/components/ui/radio-group"
|
||||
import {Clock, Database, Save, Settings, Calendar, RotateCcw} from "lucide-react"
|
||||
import {toast} from "sonner";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {
|
||||
updateOrCreateBackupRetentionPolicyAction
|
||||
} from "@/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.action";
|
||||
import {Database as DbSchema} from "@/db/schema/07_database";
|
||||
|
||||
type RetentionPolicyType = "count" | "days" | "gfs"
|
||||
|
||||
interface GFSSettings {
|
||||
daily: number
|
||||
weekly: number
|
||||
monthly: number
|
||||
yearly: number
|
||||
}
|
||||
|
||||
export interface RetentionSettings {
|
||||
type: RetentionPolicyType
|
||||
count: number
|
||||
days: number
|
||||
gfs: GFSSettings
|
||||
}
|
||||
|
||||
type BackupRetentionSettingsProps = {
|
||||
database: DbSchema,
|
||||
}
|
||||
|
||||
|
||||
export function BackupRetentionSettings({database}: BackupRetentionSettingsProps) {
|
||||
const [settings, setSettings] = useState<RetentionSettings>({
|
||||
type: "gfs",
|
||||
count: 7,
|
||||
days: 30,
|
||||
gfs: {
|
||||
daily: 7,
|
||||
weekly: 4,
|
||||
monthly: 12,
|
||||
yearly: 3,
|
||||
},
|
||||
})
|
||||
const router = useRouter()
|
||||
|
||||
const updateRetentionPolicy = useMutation({
|
||||
mutationFn: async (payload: RetentionSettings) => await updateOrCreateBackupRetentionPolicyAction({
|
||||
databaseId: database.id,
|
||||
settings: payload
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success("Retention policy updated successfully.")
|
||||
router.refresh()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("An error occurred while updating retention policy.")
|
||||
},
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
updateRetentionPolicy.mutate(settings)
|
||||
}
|
||||
const calculateTotalFiles = () => {
|
||||
if (settings.type === "gfs") {
|
||||
return settings.gfs.daily + settings.gfs.weekly + settings.gfs.monthly + settings.gfs.yearly
|
||||
}
|
||||
return settings.type === "count" ? settings.count : Math.ceil(settings.days / 1) // Assuming daily backups
|
||||
}
|
||||
|
||||
const getStorageEstimate = () => {
|
||||
const totalFiles = calculateTotalFiles()
|
||||
if (totalFiles <= 10) return "Low"
|
||||
if (totalFiles <= 30) return "Medium"
|
||||
return "High"
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col">
|
||||
|
||||
<div className="px-3 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-medium">Retention Policy Type</Label>
|
||||
<RadioGroup
|
||||
value={settings.type}
|
||||
onValueChange={(value: RetentionPolicyType) => setSettings((prev) => ({
|
||||
...prev,
|
||||
type: value
|
||||
}))}
|
||||
className="grid grid-cols-1 gap-4"
|
||||
>
|
||||
<div
|
||||
className="flex items-center space-x-3 rounded-lg border p-4 hover:bg-muted/50 transition-colors">
|
||||
<RadioGroupItem value="count" id="count"/>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="count" className="font-medium cursor-pointer">
|
||||
Keep last N backups
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Simple count-based retention (e.g., keep last 10 backups)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center space-x-3 rounded-lg border p-4 hover:bg-muted/50 transition-colors">
|
||||
<RadioGroupItem value="days" id="days"/>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="days" className="font-medium cursor-pointer">
|
||||
Keep backups for X days
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">Time-based retention (e.g., keep
|
||||
backups for 30 days)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center space-x-3 rounded-lg border p-4 hover:bg-muted/50 transition-colors bg-accent/5">
|
||||
<RadioGroupItem value="gfs" id="gfs"/>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="gfs" className="font-medium cursor-pointer flex items-center gap-2">
|
||||
GFS Rotation
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Recommended
|
||||
</Badge>
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Grandfather-Father-Son rotation for enterprise/critical systems
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<Separator/>
|
||||
|
||||
{settings.type === "count" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-muted-foreground"/>
|
||||
<Label className="font-medium">Count-Based Configuration</Label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-count">Number of backups to keep</Label>
|
||||
<Input
|
||||
id="backup-count"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={settings.count}
|
||||
onChange={(e) => setSettings((prev) => ({
|
||||
...prev,
|
||||
count: Number.parseInt(e.target.value) || 1
|
||||
}))}
|
||||
className="w-32"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Older backups beyond this count will be automatically deleted
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings.type === "days" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground"/>
|
||||
<Label className="font-medium">Time-Based Configuration</Label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="retention-days">Retention period (days)</Label>
|
||||
<Input
|
||||
id="retention-days"
|
||||
type="number"
|
||||
min="1"
|
||||
max="3650"
|
||||
value={settings.days}
|
||||
onChange={(e) => setSettings((prev) => ({
|
||||
...prev,
|
||||
days: Number.parseInt(e.target.value) || 1
|
||||
}))}
|
||||
className="w-32"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Backups older than {settings.days} days will be automatically deleted
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings.type === "gfs" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<RotateCcw className="h-4 w-4 text-muted-foreground"/>
|
||||
<Label className="font-medium">GFS Rotation Configuration</Label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="daily-backups">Daily backups</Label>
|
||||
<Input
|
||||
id="daily-backups"
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
value={settings.gfs.daily}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
gfs: {...prev.gfs, daily: Number.parseInt(e.target.value) || 1},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Keep last N daily backups</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="weekly-backups">Weekly backups</Label>
|
||||
<Input
|
||||
id="weekly-backups"
|
||||
type="number"
|
||||
min="0"
|
||||
max="52"
|
||||
value={settings.gfs.weekly}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
gfs: {...prev.gfs, weekly: Number.parseInt(e.target.value) || 0},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Keep N weekly backups</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="monthly-backups">Monthly backups</Label>
|
||||
<Input
|
||||
id="monthly-backups"
|
||||
type="number"
|
||||
min="0"
|
||||
max="120"
|
||||
value={settings.gfs.monthly}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
gfs: {...prev.gfs, monthly: Number.parseInt(e.target.value) || 0},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Keep N monthly backups</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="yearly-backups">Yearly backups</Label>
|
||||
<Input
|
||||
id="yearly-backups"
|
||||
type="number"
|
||||
min="0"
|
||||
max="50"
|
||||
value={settings.gfs.yearly}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
gfs: {...prev.gfs, yearly: Number.parseInt(e.target.value) || 0},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Keep N yearly backups</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 p-3 rounded-lg">
|
||||
<p className="text-sm font-medium mb-1">GFS Strategy Benefits:</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
<li>• Balanced storage usage with long-term retention</li>
|
||||
<li>• Automatic promotion of backups (daily → weekly → monthly → yearly)</li>
|
||||
<li>• Industry standard for enterprise backup systems</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator/>
|
||||
|
||||
<div className="rounded-lg border p-4 space-y-3 bg-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground"/>
|
||||
<span className="font-medium">Storage Impact Summary</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
getStorageEstimate() === "Low"
|
||||
? "default"
|
||||
: getStorageEstimate() === "Medium"
|
||||
? "secondary"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{getStorageEstimate()} Usage
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Estimated files per database:</span>
|
||||
<p className="font-medium">{calculateTotalFiles()} backup files</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Policy type:</span>
|
||||
<p className="font-medium capitalize">
|
||||
{settings.type === "gfs" ? "GFS Rotation" : settings.type === "count" ? "Count-based" : "Time-based"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settings.type === "gfs" && (
|
||||
<div
|
||||
className="text-xs text-muted-foreground bg-accent/10 p-2 rounded border border-accent/20">
|
||||
<strong>Note:</strong> With GFS rotation, you'll have
|
||||
approximately {calculateTotalFiles()} files per
|
||||
database per year, providing excellent long-term retention with optimized storage usage.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} disabled={updateRetentionPolicy.isPending} className="w-full">
|
||||
<Save className="h-4 w-4 mr-2"/>
|
||||
{updateRetentionPolicy.isPending ? "Saving Policy..." : "Save Retention Policy"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger} from "@/components/ui/sheet";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Database, Ruler} from "lucide-react";
|
||||
import {DatabaseWith as DbSchema, RetentionPolicy} from "@/db/schema/07_database";
|
||||
import {
|
||||
BackupRetentionSettingsForm
|
||||
} from "@/components/wrappers/dashboard/database/retention-policy/backup-retention-settings-form";
|
||||
|
||||
type RetentionPolicySheetProps = {
|
||||
database: DbSchema
|
||||
}
|
||||
|
||||
export const RetentionPolicySheet = ({database}: RetentionPolicySheetProps) => {
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Ruler />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
className="flex gap-4 p-4 w-[540px] sm:w-[800px] max-w-[800px] max-h-screen overflow-y-scroll"
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center gap-2 text-balance">
|
||||
<Database className="h-5 w-5"/>
|
||||
Backup Retention Policy
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-pretty">
|
||||
Configure how long to keep your .dump backup files. Choose from simple count-based, time-based,
|
||||
or
|
||||
enterprise GFS rotation strategies.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
{database.backupPolicy !== null ?
|
||||
<BackupRetentionSettingsForm database={database}
|
||||
defaultValues={database.retentionPolicy as RetentionPolicy}/>
|
||||
:
|
||||
<div
|
||||
className="flex flex-col items-center justify-center text-center py-12 gap-4 border rounded-lg">
|
||||
<p className="text-muted-foreground">
|
||||
No backup policy configured yet. Please configure one !
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
+37
-11
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm";
|
||||
import { deleteOrganizationAction } from "@/components/wrappers/dashboard/organization/organization.action";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { setCurrentOrganizationSlug } from "@/features/dashboard/organization-cookie";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
||||
import {deleteOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {setCurrentOrganizationSlug} from "@/features/dashboard/organization-cookie";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {toast} from "sonner";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {Trash2} from "lucide-react";
|
||||
|
||||
export type DeleteOrganizationButtonProps = {
|
||||
organizationSlug: string;
|
||||
@@ -13,6 +14,8 @@ export type DeleteOrganizationButtonProps = {
|
||||
|
||||
export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) => {
|
||||
const router = useRouter();
|
||||
const {data: organizations, refetch} = authClient.useListOrganizations();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => deleteOrganizationAction(props.organizationSlug),
|
||||
|
||||
@@ -22,8 +25,9 @@ export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) =
|
||||
organizationSlug: "default",
|
||||
});
|
||||
router.push("/");
|
||||
|
||||
toast.success(result.data.actionSuccess?.message || "Organization deleted.");
|
||||
router.refresh()
|
||||
refetch()
|
||||
} else {
|
||||
// @ts-ignore
|
||||
const errorMsg = result?.data?.actionError?.message || result?.data?.actionError?.messageParams?.message || "Failed to delete the organization.";
|
||||
@@ -38,13 +42,35 @@ export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) =
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
<ButtonWithConfirm
|
||||
onClick={() => {
|
||||
mutation.mutate();
|
||||
title="Delete Organization"
|
||||
description="Are you sure you want to remove this organization? This action cannot be undone."
|
||||
button={{
|
||||
main: {
|
||||
text: "Delete Organization",
|
||||
variant: "outline",
|
||||
icon: <Trash2 color="red"/>,
|
||||
},
|
||||
confirm: {
|
||||
className: "w-full",
|
||||
text: "Delete",
|
||||
icon: <Trash2/>,
|
||||
variant: "destructive",
|
||||
onClick: () => {
|
||||
mutation.mutate();
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
className: "w-full",
|
||||
text: "Cancel",
|
||||
icon: <Trash2/>,
|
||||
variant: "outline",
|
||||
},
|
||||
}}
|
||||
isPending={mutation.isPending}
|
||||
text="Delete Organization"
|
||||
variant="destructive"
|
||||
/>
|
||||
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import {ComboBox} from "@/components/wrappers/common/combobox";
|
||||
import {useSidebar} from "@/components/ui/sidebar";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
|
||||
|
||||
export function OrganizationCombobox() {
|
||||
@@ -16,9 +15,6 @@ export function OrganizationCombobox() {
|
||||
|
||||
if (!organizations) return null;
|
||||
|
||||
console.log("organizations", organizations);
|
||||
console.log("activeOrganization", activeOrganization);
|
||||
|
||||
const values = organizations.map((organization) => {
|
||||
return {
|
||||
value: organization.slug,
|
||||
@@ -26,8 +22,6 @@ export function OrganizationCombobox() {
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
|
||||
const onValueChange = async (slug: string) => {
|
||||
|
||||
await authClient.organization.setActive({
|
||||
|
||||
+2
-2
@@ -20,14 +20,14 @@ import {
|
||||
OrganizationFormSchema,
|
||||
OrganizationFormType
|
||||
} from "@/components/wrappers/dashboard/organization/organization-form/organization-form.schema";
|
||||
import {MemberWithUser, OrganizationWithMembers} from "@/db/schema/02_organization";
|
||||
import {MemberWithUser, OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {
|
||||
deleteOrganizationAction,
|
||||
updateOrganizationAction
|
||||
} from "@/components/wrappers/dashboard/organization/organization.action";
|
||||
import {toast} from "sonner";
|
||||
import {User as BetterAuthUser} from "better-auth";
|
||||
import {User} from "@/db/schema/01_user";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
|
||||
export type organizationFormProps = {
|
||||
defaultValues?: OrganizationWithMembers;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import {userAction} from "@/safe-actions";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {OrganizationSchema} from "@/components/wrappers/dashboard/organization/organization.schema";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {z} from "zod";
|
||||
@@ -11,7 +11,7 @@ import {db} from "@/db";
|
||||
import {and, eq, inArray} from "drizzle-orm";
|
||||
import {auth, checkSlugOrganization, createOrganization, deleteOrganization} from "@/lib/auth/auth";
|
||||
import {slugify} from "@/utils/slugify";
|
||||
import {Organization} from "@/db/schema/02_organization";
|
||||
import {Organization} from "@/db/schema/03_organization";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {headers} from "next/headers";
|
||||
|
||||
@@ -77,7 +77,6 @@ export const updateOrganizationAction = userAction
|
||||
.action(async ({parsedInput, ctx}): Promise<ServerActionResult<Organization>> => {
|
||||
try {
|
||||
const newUserList = parsedInput.data.users;
|
||||
console.log(parsedInput);
|
||||
const organization = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.id, parsedInput.organizationId),
|
||||
with: {
|
||||
@@ -222,7 +221,13 @@ export const deleteOrganizationAction = userAction.schema(z.string()).action(
|
||||
let deletedOrganization: Organization;
|
||||
|
||||
try {
|
||||
deletedOrganization = await deleteOrganization(org.id) as Organization;
|
||||
// TODO : Improve with better auth, always getting 403 error
|
||||
// deletedOrganization = await deleteOrganization(org.id) as Organization;
|
||||
[deletedOrganization] = await db
|
||||
.delete(drizzleDb.schemas.organization)
|
||||
.where(eq(drizzleDb.schemas.organization.id, org.id))
|
||||
.returning();
|
||||
|
||||
} catch (authError: any) {
|
||||
console.error("Auth deletion failed:", authError);
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,8 @@ import { uploadImageAction } from "@/features/upload/public/upload.action";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { updateImageUserAction } from "@/components/wrappers/dashboard/profile/avatar/avatar.action";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { User } from "@/db/schema/01_user";
|
||||
import { User } from "@/db/schema/02_user";
|
||||
import {ChangeEvent} from "react";
|
||||
|
||||
export type AvatarWithUploadProps = {
|
||||
user: User;
|
||||
@@ -43,7 +44,7 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleImageUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file.type.includes("image")) {
|
||||
@@ -53,6 +54,8 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
|
||||
submitImage.mutate(file);
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="relative ">
|
||||
<Avatar className="size-14 mr-3 ">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
import { db } from "@/db";
|
||||
import { userAction } from "@/safe-actions";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
+31
-12
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm";
|
||||
import { signOut } from "@/lib/auth/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { deleteUserAction } from "./delete-account.action";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {Trash2} from "lucide-react";
|
||||
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
||||
import {signOut} from "@/lib/auth/auth-client";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {deleteUserAction} from "./delete-account.action";
|
||||
|
||||
export type ButtonDeleteAccountProps = {
|
||||
text?: string;
|
||||
@@ -29,14 +29,33 @@ export const ButtonDeleteAccount = (props: ButtonDeleteAccountProps) => {
|
||||
|
||||
return (
|
||||
<ButtonWithConfirm
|
||||
text={props.text ? props.text : ""}
|
||||
onClick={() => {
|
||||
mutation.mutate();
|
||||
title={props.text ? props.text : ""}
|
||||
description="Are you sure you want to delete your account ? This action cannot be undone."
|
||||
button={{
|
||||
main: {
|
||||
text: props.text ? props.text : "",
|
||||
variant: "outline",
|
||||
icon: <Trash2 color="red"/>,
|
||||
},
|
||||
confirm: {
|
||||
className: "w-full",
|
||||
text: "Delete",
|
||||
icon: <Trash2/>,
|
||||
variant: "destructive",
|
||||
onClick: () => {
|
||||
mutation.mutate();
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
className: "w-full",
|
||||
text: "Cancel",
|
||||
icon: <Trash2/>,
|
||||
variant: "outline",
|
||||
},
|
||||
}}
|
||||
variant={"destructive"}
|
||||
isPending={mutation.isPending}
|
||||
className="gap-2"
|
||||
icon={<Trash2 />}
|
||||
/>
|
||||
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
+18
-10
@@ -1,28 +1,36 @@
|
||||
"use server";
|
||||
import { userAction } from "@/safe-actions";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import { z } from "zod";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { db } from "@/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
|
||||
|
||||
export const deleteUserAction = userAction.schema(z.string()).action(async ({ parsedInput, ctx }) => {
|
||||
const userId = parsedInput.length > 0 ? parsedInput : ctx.user.id;
|
||||
const uuid = uuidv4();
|
||||
|
||||
const [updatedUser] = await db
|
||||
.update(drizzleDb.schemas.user)
|
||||
.set({
|
||||
email: `${uuid}@portabase.com`,
|
||||
name: `${uuid}`,
|
||||
//deleted: true,
|
||||
//todo: add deleted
|
||||
})
|
||||
|
||||
// const [updatedUser] = await db
|
||||
// .update(drizzleDb.schemas.user)
|
||||
// .set({
|
||||
// email: `${uuid}@portabase.com`,
|
||||
// name: `${uuid}`,
|
||||
// //deleted: true,
|
||||
// //todo: add deleted
|
||||
// })
|
||||
// .where(eq(drizzleDb.schemas.user.id, userId))
|
||||
// .returning();
|
||||
const [deletedUser] = await db
|
||||
.delete(drizzleDb.schemas.user)
|
||||
.where(eq(drizzleDb.schemas.user.id, userId))
|
||||
.returning();
|
||||
|
||||
|
||||
return {
|
||||
data: updatedUser,
|
||||
data: deletedUser,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use server";
|
||||
import { userAction } from "@/safe-actions";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import { z } from "zod";
|
||||
import { UserSchema } from "@/components/wrappers/dashboard/profile/user-form/user-form.schema";
|
||||
import { db } from "@/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {revokeSession, unlinkAccount} from "@/lib/auth/auth";
|
||||
|
||||
export const updateUserAction = userAction
|
||||
.schema(
|
||||
@@ -19,3 +20,23 @@ export const updateUserAction = userAction
|
||||
data: updatedUser,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
export const deleteUserSessionAction = userAction.schema(z.string()).action(async ({ parsedInput }) => {
|
||||
const status = await revokeSession(parsedInput);
|
||||
return status;
|
||||
});
|
||||
|
||||
|
||||
export const unlinkUserProviderAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
provider: z.string(),
|
||||
account: z.string(),
|
||||
})
|
||||
)
|
||||
.action(async ({ parsedInput }) => {
|
||||
const status = await unlinkAccount(parsedInput.provider, parsedInput.account);
|
||||
|
||||
return status;
|
||||
});
|
||||
|
||||
@@ -11,13 +11,26 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { UserSchema, UserType } from "@/components/wrappers/dashboard/profile/user-form/user-form.schema";
|
||||
import { toast } from "sonner";
|
||||
import { updateUserAction } from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
|
||||
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
||||
import {sessionsColumns} from "@/components/wrappers/dashboard/admin/admin-user-tab/sessions/table-columns";
|
||||
import {accountsColumns} from "@/components/wrappers/dashboard/admin/admin-user-tab/accounts/table-columns";
|
||||
import {Session} from "better-auth";
|
||||
|
||||
export type userFormProps = {
|
||||
export type UserFormProps = {
|
||||
defaultValues?: UserType;
|
||||
userId?: string;
|
||||
sessions: Session[];
|
||||
accounts: {
|
||||
id: string;
|
||||
provider: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
accountId: string;
|
||||
scopes: string[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export const UserForm = (props: userFormProps) => {
|
||||
export const UserForm = (props: UserFormProps) => {
|
||||
const isCreate = !Boolean(props.defaultValues);
|
||||
|
||||
const form = useZodForm({
|
||||
@@ -29,8 +42,6 @@ export const UserForm = (props: userFormProps) => {
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: UserType) => {
|
||||
console.log("values", values);
|
||||
console.log(props.userId);
|
||||
const updateUser = await updateUserAction({
|
||||
id: props.userId ?? "-",
|
||||
data: values,
|
||||
@@ -49,6 +60,7 @@ export const UserForm = (props: userFormProps) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4 h-full py-4">
|
||||
<TooltipProvider>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -93,6 +105,25 @@ export const UserForm = (props: userFormProps) => {
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active sessions</CardTitle>
|
||||
<CardDescription>Manage your active sessions</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable columns={sessionsColumns} data={props.sessions} enableSelect={false}/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Auth providers</CardTitle>
|
||||
<CardDescription>Manage your active auth providers</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable columns={accountsColumns} data={props.accounts} enableSelect={false}/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+32
-12
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { deleteProjectAction } from "@/components/wrappers/dashboard/projects/button-delete-project/delete-project.action";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import {Trash2} from "lucide-react";
|
||||
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {
|
||||
deleteProjectAction
|
||||
} from "@/components/wrappers/dashboard/projects/button-delete-project/delete-project.action";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {toast} from "sonner";
|
||||
|
||||
export type ButtonDeleteProjectProps = {
|
||||
text?: string;
|
||||
@@ -27,15 +29,33 @@ export const ButtonDeleteProject = (props: ButtonDeleteProjectProps) => {
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
<ButtonWithConfirm
|
||||
text={props.text ? props.text : ""}
|
||||
onClick={() => {
|
||||
mutation.mutate();
|
||||
title={props.text ? props.text : ""}
|
||||
description="Are you sure you want to delete this project ? This action cannot be undone."
|
||||
button={{
|
||||
main: {
|
||||
text: props.text ? props.text : "",
|
||||
variant: "outline",
|
||||
icon: <Trash2 color="red"/>,
|
||||
},
|
||||
confirm: {
|
||||
className: "w-full",
|
||||
text: "Delete",
|
||||
icon: <Trash2/>,
|
||||
variant: "destructive",
|
||||
onClick: () => {
|
||||
mutation.mutate();
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
className: "w-full",
|
||||
text: "Cancel",
|
||||
icon: <Trash2/>,
|
||||
variant: "outline",
|
||||
},
|
||||
}}
|
||||
variant={"destructive"}
|
||||
isPending={mutation.isPending}
|
||||
className="gap-2"
|
||||
icon={<Trash2 />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+10
-2
@@ -1,16 +1,22 @@
|
||||
"use server";
|
||||
|
||||
import {userAction} from "@/safe-actions";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {z} from "zod";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
export const deleteProjectAction = userAction.schema(z.string()).action(async ({parsedInput}): Promise<ServerActionResult<typeof drizzleDb.schemas.project.$inferSelect>> => {
|
||||
try {
|
||||
const uuid = uuidv4();
|
||||
await db
|
||||
.update(drizzleDb.schemas.database)
|
||||
.set({
|
||||
projectId: null,
|
||||
})
|
||||
.where(eq(drizzleDb.schemas.database.projectId, parsedInput));
|
||||
|
||||
const updatedProjects = await db
|
||||
.update(drizzleDb.schemas.project)
|
||||
@@ -27,6 +33,7 @@ export const deleteProjectAction = userAction.schema(z.string()).action(async ({
|
||||
throw new Error("Project not found or update failed");
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: updatedProject,
|
||||
@@ -36,6 +43,7 @@ export const deleteProjectAction = userAction.schema(z.string()).action(async ({
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client"
|
||||
import {backupColumns} from "@/features/dashboard/backup/columns";
|
||||
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {MoreHorizontal, Trash2} from "lucide-react";
|
||||
import {FilterItem, FiltersDropdown} from "@/components/wrappers/common/table/filters";
|
||||
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
||||
import {useMemo, useState} from "react";
|
||||
import {Backup, DatabaseWith} from "@/db/schema/07_database";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {deleteBackupAction} from "@/features/dashboard/restore/restore.action";
|
||||
import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
|
||||
|
||||
type DatabaseBackupListProps = {
|
||||
isAlreadyRestore: boolean;
|
||||
settings: Setting;
|
||||
database: DatabaseWith;
|
||||
backups: Backup[];
|
||||
}
|
||||
|
||||
|
||||
export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
|
||||
|
||||
const items = [
|
||||
{label: "Deleted", value: "deleted"},
|
||||
{label: "Available", value: "available"},
|
||||
]
|
||||
|
||||
const [selectedFilters, setSelectedFilters] = useState<FilterItem[]>([items[1]]);
|
||||
const router = useRouter();
|
||||
|
||||
const filteredBackups = useMemo(() => {
|
||||
if (!props.backups) return [];
|
||||
|
||||
return props.backups.filter(backup => {
|
||||
|
||||
// --- Status Filter ---
|
||||
if (selectedFilters.length > 0) {
|
||||
const selectedValues = selectedFilters.map(f => f.value);
|
||||
const status = backup.deletedAt != null ? "deleted" : "available";
|
||||
if (!selectedValues.includes(status)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [props.backups, selectedFilters]);
|
||||
|
||||
|
||||
const handleSelectFilter = (item: FilterItem) => {
|
||||
setSelectedFilters(prev =>
|
||||
prev.some(f => f.value === item.value)
|
||||
? prev.filter(f => f.value !== item.value)
|
||||
: [...prev, item]
|
||||
);
|
||||
};
|
||||
|
||||
const clearFilters = () => setSelectedFilters([]);
|
||||
|
||||
|
||||
const mutationDeleteBackups = useMutation({
|
||||
mutationFn: async (backups: Backup[]) => {
|
||||
const results = await Promise.all(
|
||||
backups.map(async (backup) => {
|
||||
if (backup.deletedAt == null) {
|
||||
const backupDeleted = await deleteBackupAction({
|
||||
backupId: backup.id,
|
||||
databaseId: backup.databaseId,
|
||||
file: backup.file!,
|
||||
projectSlug: props.database?.project?.slug!
|
||||
});
|
||||
return {
|
||||
success: backupDeleted?.data?.success,
|
||||
message: backupDeleted?.data?.success
|
||||
? backupDeleted?.data?.actionSuccess?.message
|
||||
// @ts-ignore
|
||||
: restoration?.data?.actionError.message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: `Already deleted this backup (ref: ${backup.id}).`,
|
||||
}
|
||||
})
|
||||
);
|
||||
results.forEach((result) => {
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
});
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={backupColumns(props.isAlreadyRestore, props.settings, props.database)}
|
||||
data={filteredBackups}
|
||||
enablePagination
|
||||
selectedActions={(rows) => (
|
||||
<div className="flex justify-start md:justify-between gap-3 md:gap-0 items-center w-full ml-0">
|
||||
<div className="flex gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ButtonWithLoading
|
||||
variant="outline"
|
||||
text="Actions"
|
||||
onClick={() => {
|
||||
|
||||
}}
|
||||
disabled={rows.length === 0 || mutationDeleteBackups.isPending}
|
||||
icon={<MoreHorizontal/>}
|
||||
isPending={mutationDeleteBackups.isPending}
|
||||
size="sm"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
console.log("Deleting rows:", rows)
|
||||
await mutationDeleteBackups.mutateAsync(rows)
|
||||
}}
|
||||
className="text-red-600 focus:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2"/>
|
||||
Delete Selected
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<FiltersDropdown
|
||||
items={items}
|
||||
selectedItems={selectedFilters}
|
||||
onSelect={handleSelectFilter}
|
||||
clearFilters={clearFilters}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
|
||||
import {formatDateLastContact} from "@/utils/date-formatting";
|
||||
import {Database} from "@/db/schema/06_database";
|
||||
import {Database} from "@/db/schema/07_database";
|
||||
import {DatabaseBackup, Server, CheckCircle, Clock} from "lucide-react";
|
||||
|
||||
export type DatabaseKpiPro = {
|
||||
successRate: any;
|
||||
database: Database;
|
||||
totalBackups: number;
|
||||
availableBackups: number;
|
||||
};
|
||||
|
||||
export const DatabaseKpi = (props: DatabaseKpiPro) => {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between gap-8 mb-6">
|
||||
<Card className="w-full sm:w-auto flex-1">
|
||||
<CardHeader className="font-bold text-xl">Backups</CardHeader>
|
||||
<CardContent>{props.totalBackups}</CardContent>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Available Backups</CardTitle>
|
||||
<DatabaseBackup className="h-4 w-4 text-muted-foreground"/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{props.availableBackups}</div>
|
||||
<p className="text-xs text-muted-foreground">Backups currently available</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="w-full sm:w-auto flex-1">
|
||||
<CardHeader className="font-bold text-xl">Success rate</CardHeader>
|
||||
<CardContent>{props.successRate ? `${props.successRate} %` : "Unavailable for now."}</CardContent>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Backups</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground"/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{props.totalBackups}</div>
|
||||
<p className="text-xs text-muted-foreground">Total backups recorded</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="w-full sm:w-auto flex-1">
|
||||
<CardHeader className="font-bold text-xl">Last contact</CardHeader>
|
||||
<CardContent>{formatDateLastContact(props.database.lastContact)}</CardContent>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Success Rate</CardTitle>
|
||||
<CheckCircle className="h-4 w-4 text-muted-foreground"/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{props.successRate ? `${props.successRate.toFixed(0)} %` : "Unavailable"}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Backup success rate</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="w-full sm:w-auto flex-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Last Contact</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground"/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatDateLastContact(props.database.lastContact)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Time of last backup contact</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client"
|
||||
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
||||
import {restoreColumns} from "@/features/dashboard/restore/columns";
|
||||
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {MoreHorizontal, Trash2} from "lucide-react";
|
||||
import {Restoration} from "@/db/schema/07_database";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {deleteRestoreAction} from "@/features/dashboard/restore/restore.action";
|
||||
import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
|
||||
|
||||
type DatabaseRestoreListProps = {
|
||||
isAlreadyRestore: boolean;
|
||||
restorations: Restoration[];
|
||||
}
|
||||
|
||||
export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const mutationDeleteRestorations = useMutation({
|
||||
mutationFn: async (restorations: Restoration[]) => {
|
||||
const results = await Promise.all(
|
||||
restorations.map(async (restoration) => {
|
||||
const restorationDeleted = await deleteRestoreAction({
|
||||
restorationId: restoration.id,
|
||||
});
|
||||
return {
|
||||
success: restorationDeleted?.data?.success,
|
||||
message: restorationDeleted?.data?.success
|
||||
? restorationDeleted?.data?.actionSuccess?.message
|
||||
// @ts-ignore
|
||||
: restorationDeleted?.data?.actionError.message,
|
||||
};
|
||||
|
||||
|
||||
})
|
||||
);
|
||||
results.forEach((result) => {
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
});
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={restoreColumns(props.isAlreadyRestore)}
|
||||
data={props.restorations}
|
||||
enablePagination
|
||||
selectedActions={(rows) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ButtonWithLoading
|
||||
variant="outline"
|
||||
text="Actions"
|
||||
onClick={() => {
|
||||
}}
|
||||
disabled={rows.length === 0 || mutationDeleteRestorations.isPending}
|
||||
icon={<MoreHorizontal/>}
|
||||
isPending={mutationDeleteRestorations.isPending}
|
||||
size="sm"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await mutationDeleteRestorations.mutateAsync(rows)
|
||||
}}
|
||||
disabled={props.isAlreadyRestore}
|
||||
className="text-red-600 focus:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2"/>
|
||||
Delete Selected
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { eventUpdate } from "@/types/events";
|
||||
import { backupColumns } from "@/features/dashboard/backup/columns";
|
||||
import { restoreColumns } from "@/features/dashboard/restore/columns";
|
||||
import { DataTable } from "@/components/wrappers/common/table/data-table";
|
||||
import {Backup, Database, Restoration} from "@/db/schema/06_database";
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
||||
import {useEffect, useState} from "react";
|
||||
import {useRouter, useSearchParams} from "next/navigation";
|
||||
import {eventUpdate} from "@/types/events";
|
||||
import {Backup, Database, DatabaseWith, Restoration} from "@/db/schema/07_database";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
import {DatabaseBackupList} from "@/components/wrappers/dashboard/projects/database/database-backup-list";
|
||||
import {DatabaseRestoreList} from "@/components/wrappers/dashboard/projects/database/database-restore-list";
|
||||
|
||||
export type DatabaseTabsProps = {
|
||||
settings: Setting
|
||||
backups: Backup[];
|
||||
restorations: Restoration[];
|
||||
isAlreadyRestore: boolean;
|
||||
database: Database;
|
||||
database: DatabaseWith;
|
||||
};
|
||||
|
||||
export const DatabaseTabs = (props: DatabaseTabsProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "backup");
|
||||
|
||||
useEffect(() => {
|
||||
const eventSource = new EventSource("/api/events");
|
||||
|
||||
eventSource.addEventListener("modification", (event) => {
|
||||
const data: eventUpdate = JSON.parse(event.data);
|
||||
if (data.update) {
|
||||
@@ -33,18 +37,34 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const newTab = searchParams.get("tab") ?? "backup";
|
||||
setTab(newTab);
|
||||
}, [searchParams]);
|
||||
|
||||
const handleChangeTab = (value: string) => {
|
||||
router.push(`?tab=${value}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs className="flex flex-col flex-1" defaultValue="backup">
|
||||
<Tabs className="flex flex-col flex-1" value={tab} onValueChange={handleChangeTab}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="backup">Backup</TabsTrigger>
|
||||
<TabsTrigger value="restore">Restoration</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent className="h-full justify-between" value="backup">
|
||||
<DataTable columns={backupColumns(props.isAlreadyRestore)} data={props.backups} enablePagination />
|
||||
<DatabaseBackupList
|
||||
isAlreadyRestore={props.isAlreadyRestore}
|
||||
settings={props.settings}
|
||||
database={props.database}
|
||||
backups={props.backups}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent className="h-full justify-between" value="restore">
|
||||
<DataTable columns={restoreColumns(props.isAlreadyRestore)} data={props.restorations} enablePagination />
|
||||
<DatabaseRestoreList
|
||||
isAlreadyRestore={props.isAlreadyRestore}
|
||||
restorations={props.restorations}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import {ProjectWith} from "@/db/schema/05_project";
|
||||
import {ProjectWith} from "@/db/schema/06_project";
|
||||
|
||||
export type projectCardProps = {
|
||||
data: ProjectWith;
|
||||
|
||||
@@ -5,7 +5,7 @@ import Image from "next/image";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { ConnectionCircle } from "@/components/wrappers/common/connection-circle";
|
||||
import { formatDateLastContact } from "@/utils/date-formatting";
|
||||
import {Database} from "@/db/schema/06_database";
|
||||
import {Database} from "@/db/schema/07_database";
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
|
||||
|
||||
export type projectDatabaseCardProps = {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { userAction } from "@/safe-actions";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import { ProjectSchema } from "@/components/wrappers/dashboard/projects/project-form/project-form.schema";
|
||||
import { z } from "zod";
|
||||
import { ServerActionResult } from "@/types/action-type";
|
||||
import { db } from "@/db";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import {Project} from "@/db/schema/05_project";
|
||||
import {Project} from "@/db/schema/06_project";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {Database} from "@/db/schema/06_database";
|
||||
import {Database} from "@/db/schema/07_database";
|
||||
import {slugify} from "@/utils/slugify";
|
||||
|
||||
export const createProjectAction = userAction
|
||||
@@ -89,13 +89,13 @@ export const updateProjectAction = userAction
|
||||
if (databasesToRemove.length > 0) {
|
||||
await db.update(drizzleDb.schemas.database).set({ projectId: null }).where(inArray(drizzleDb.schemas.database.id, databasesToRemove));
|
||||
}
|
||||
const slug = slugify(parsedInput.data.name);
|
||||
// const slug = slugify(parsedInput.data.name);
|
||||
|
||||
const [updatedProject] = await db
|
||||
.update(drizzleDb.schemas.project)
|
||||
.set({
|
||||
name: parsedInput.data.name,
|
||||
slug: slug,
|
||||
// slug: slug,
|
||||
})
|
||||
.where(eq(drizzleDb.schemas.project.id, parsedInput.projectId))
|
||||
.returning();
|
||||
|
||||
@@ -11,8 +11,8 @@ import { createProjectAction, updateProjectAction } from "@/components/wrappers/
|
||||
import { useRouter } from "next/navigation";
|
||||
import { MultiSelect } from "@/components/wrappers/common/multiselect/multi-select";
|
||||
import { toast } from "sonner";
|
||||
import {DatabaseWith} from "@/db/schema/06_database";
|
||||
import {Organization} from "@/db/schema/02_organization";
|
||||
import {DatabaseWith} from "@/db/schema/07_database";
|
||||
import {Organization} from "@/db/schema/03_organization";
|
||||
|
||||
export type projectFormProps = {
|
||||
defaultValues?: ProjectType;
|
||||
@@ -27,7 +27,7 @@ export const ProjectForm = (props: projectFormProps) => {
|
||||
const formatDatabasesList = (databases: DatabaseWith[]) => {
|
||||
return databases.map((database) => ({
|
||||
value: database.id,
|
||||
label: `${database.name} (${database.id}) | ${database.agent.name}`,
|
||||
label: `${database.name} (${database.agentDatabaseId}) | ${database.agent?.name}`,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -47,7 +47,6 @@ export const ProjectForm = (props: projectFormProps) => {
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: ProjectType) => {
|
||||
console.log(values);
|
||||
if (!isCreate && !props.projectId) {
|
||||
throw new Error("Project ID is required for updates");
|
||||
}
|
||||
@@ -89,15 +88,15 @@ export const ProjectForm = (props: projectFormProps) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Project 1" {...field} />
|
||||
<Input placeholder="Project 1" {...field} value={field.value ?? ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {ColumnDef} from "@tanstack/react-table";
|
||||
import {MemberWithUser} from "@/db/schema/02_organization";
|
||||
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||
import {useState} from "react";
|
||||
import {authClient, useSession} from "@/lib/auth/auth-client";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
||||
import {usersColumnsAdmin} from "@/components/wrappers/dashboard/admin/columns-users";
|
||||
import {MemberWithUser, Organization, OrganizationWithMembers} from "@/db/schema/02_organization";
|
||||
import {OrganizationInvitation} from "@/db/schema/04_invitation";
|
||||
import {MemberWithUser, Organization, OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {OrganizationInvitation} from "@/db/schema/05_invitation";
|
||||
import {organizationMemberColumns} from "@/components/wrappers/dashboard/settings/columns-organization-members";
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use server";
|
||||
import {userAction} from "@/safe-actions";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {z} from "zod";
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
|
||||
@@ -15,25 +15,38 @@ export type evolutionLineChartProps = {
|
||||
export function EvolutionLineChart(props: evolutionLineChartProps) {
|
||||
const { data } = props;
|
||||
|
||||
// Process data to calculate cumulative count
|
||||
const cumulativeData = data.reduce(
|
||||
(acc, backup) => {
|
||||
const date = backup.createdAt.toISOString().split("T")[0]; // Format as YYYY-MM-DD
|
||||
// const cumulativeData = data.reduce(
|
||||
// (acc, backup) => {
|
||||
// const date = backup.createdAt.toISOString().split("T")[0]; // Format as YYYY-MM-DD
|
||||
//
|
||||
// // Increment count for the current date or initialize it
|
||||
// if (acc.length && acc[acc.length - 1].date === date) {
|
||||
// acc[acc.length - 1].count += 1;
|
||||
// } else {
|
||||
// const lastCount = acc.length ? acc[acc.length - 1].count : 0;
|
||||
// acc.push({ date, count: lastCount + 1 });
|
||||
// }
|
||||
//
|
||||
// return acc;
|
||||
// },
|
||||
// [] as { date: string; count: number }[]
|
||||
// );
|
||||
const dailyData = data
|
||||
.reduce((acc, backup) => {
|
||||
const date = backup.createdAt.toISOString().split("T")[0]; // Format: YYYY-MM-DD
|
||||
|
||||
// Increment count for the current date or initialize it
|
||||
if (acc.length && acc[acc.length - 1].date === date) {
|
||||
acc[acc.length - 1].count += 1;
|
||||
// Find if the date already exists in the accumulator
|
||||
const existing = acc.find(item => item.date === date);
|
||||
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
} else {
|
||||
const lastCount = acc.length ? acc[acc.length - 1].count : 0;
|
||||
acc.push({ date, count: lastCount + 1 });
|
||||
acc.push({ date, count: 1 });
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[] as { date: string; count: number }[]
|
||||
);
|
||||
}, [] as { date: string; count: number }[]);
|
||||
|
||||
console.log(cumulativeData);
|
||||
|
||||
const chartConfig = {
|
||||
date: {
|
||||
@@ -50,7 +63,7 @@ export function EvolutionLineChart(props: evolutionLineChartProps) {
|
||||
<ChartContainer config={chartConfig}>
|
||||
<LineChart
|
||||
accessibilityLayer
|
||||
data={cumulativeData}
|
||||
data={dailyData}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
@@ -68,7 +81,9 @@ export function EvolutionLineChart(props: evolutionLineChartProps) {
|
||||
/>
|
||||
<YAxis />
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
|
||||
<Line dataKey="count" type="linear" stroke="var(--color-desktop)" strokeWidth={2} dot={false} />
|
||||
<Line dataKey="count" type="linear" stroke="#60a5fa" strokeWidth={2} dot={false} />
|
||||
|
||||
{/*<Line dataKey="count" type="linear" stroke="var(--color-desktop)" strokeWidth={2} dot={false} />*/}
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
|
||||
+9
-9
@@ -1,13 +1,13 @@
|
||||
import {drizzle} from "drizzle-orm/node-postgres";
|
||||
|
||||
import * as settings from "./schema/00_setting";
|
||||
import * as user from "./schema/01_user";
|
||||
import * as organisation from "./schema/02_organization";
|
||||
import * as invitation from "./schema/03_member";
|
||||
import * as member from "./schema/04_invitation";
|
||||
import * as project from "./schema/05_project";
|
||||
import * as agent from "./schema/07_agent";
|
||||
import * as database from "./schema/06_database";
|
||||
import * as settings from "./schema/01_setting";
|
||||
import * as user from "./schema/02_user";
|
||||
import * as organisation from "./schema/03_organization";
|
||||
import * as invitation from "./schema/04_member";
|
||||
import * as member from "./schema/05_invitation";
|
||||
import * as project from "./schema/06_project";
|
||||
import * as agent from "./schema/08_agent";
|
||||
import * as database from "./schema/07_database";
|
||||
|
||||
|
||||
import {Pool} from "pg";
|
||||
@@ -37,7 +37,7 @@ export const schemas = {
|
||||
|
||||
export const db = drizzle({
|
||||
client: pool,
|
||||
logger: true,
|
||||
logger: process.env.NODE_ENV != 'production',
|
||||
schema: schemas,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TYPE "public"."retention_policy_type" AS ENUM('count', 'days', 'gfs');--> statement-breakpoint
|
||||
CREATE TABLE "retention_policies" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"database_id" uuid NOT NULL,
|
||||
"type" "retention_policy_type" NOT NULL,
|
||||
"count" integer DEFAULT 7,
|
||||
"days" integer DEFAULT 30,
|
||||
"gfs_daily" integer DEFAULT 7,
|
||||
"gfs_weekly" integer DEFAULT 4,
|
||||
"gfs_monthly" integer DEFAULT 12,
|
||||
"gfs_yearly" integer DEFAULT 3,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "retention_policies" ADD CONSTRAINT "retention_policies_database_id_databases_id_fk" FOREIGN KEY ("database_id") REFERENCES "public"."databases"("id") ON DELETE cascade ON UPDATE no action;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user