Compare commits

...
16 Commits
Author SHA1 Message Date
charlesgauthereau c01440f9d4 chore(release): 1.2.5-rc.15 2026-02-11 12:34:12 +01:00
Charles GTEandGitHub 39584dd7a1 Merge pull request #69 from Portabase/feature
UI/UX enhancements
2026-02-11 12:33:16 +01:00
Charles GTEandGitHub d4cde2ba9c Merge pull request #66 from Portabase/feat/refresh
Feat/refresh pagination
2026-02-11 12:18:52 +01:00
charlesgauthereau 824ef4528e fix: review and refactoring. 2026-02-11 12:18:25 +01:00
charlesgauthereau feb06a85ae Merge branch 'feature' into feat/refresh
# Conflicts:
#	app/api/agent/[agentId]/backup/route.ts
#	app/api/events/route.ts
#	src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx
#	src/components/wrappers/dashboard/database/backup/actions/backup-actions.action.ts
#	src/components/wrappers/dashboard/database/channels-policy/policy-form.tsx
#	src/lib/tasks/database/index.ts
2026-02-11 12:10:33 +01:00
Charles GTEandGitHub cfad645e33 Merge pull request #68 from Portabase/fix/large-files-storage-backend-v3
fix/large-files-storage-backend-v3
2026-02-11 12:00:57 +01:00
charlesgauthereau 675f8dec20 Merge branch 'main' into fix/large-files-storage-backend-v3
# Conflicts:
#	src/features/storages/providers/s3.ts
2026-02-11 11:54:09 +01:00
charlesgauthereau 11e85b613c chore(release): 1.2.5-rc.14 2026-02-10 20:50:33 +01:00
Théo LAGACHE 0a2ca8c0b6 add: font 2026-02-10 15:29:20 +01:00
Théo LAGACHE 921a355ae0 fix: project details table refresh 2026-02-10 11:44:45 +01:00
Charles GTEandGitHub a49b6d6573 Merge pull request #61 from Portabase/revert-57-fix/s3
Revert "chore(release): 1.2.5-rc.1"
2026-02-04 09:57:01 +01:00
Charles GTEandGitHub baffd2efaf Revert "chore(release): 1.2.5-rc.1" 2026-02-04 09:56:34 +01:00
Charles GTEandGitHub 292693a1ed Merge pull request #57 from Portabase/fix/s3
chore(release): 1.2.5-rc.1
2026-02-04 09:56:11 +01:00
killianlarcher db1a0235ed chore(release): 1.2.5-rc.1 2026-01-31 14:18:12 +01:00
Killian LarcherandGitHub 2cd6a7cae0 Merge pull request #56 from Portabase/fix/s3
fix(storage): add test button when creating channel (not only for existing one) and add region parameter for S3.
2026-01-31 13:03:58 +01:00
killianlarcher bd80fdc960 fix(storage): add test button when creating channel (not only for existing one) and add region parameter for S3. 2026-01-31 12:41:01 +01:00
78 changed files with 879 additions and 566 deletions
+4 -4
View File
@@ -41,8 +41,8 @@ jobs:
- name: Login to Docker
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME_2 }}
password: ${{ secrets.DOCKER_PASSWORD_2 }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set image tag
id: set-tags
@@ -99,8 +99,8 @@ jobs:
- name: Login to Docker
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME_2 }}
password: ${{ secrets.DOCKER_PASSWORD_2 }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Create and push manifest list
working-directory: /tmp/digests
+2 -2
View File
@@ -26,5 +26,5 @@ keywords:
- web-ui
- agent
license: Apache-2.0
version: 1.2.5-rc.13
date-released: "2026-02-10"
version: 1.2.5-rc.15
date-released: "2026-02-11"
@@ -1,21 +1,13 @@
import {PageParams} from "@/types/next";
import {notFound, redirect} from "next/navigation";
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
import {Page} from "@/features/layout/page";
import {db} from "@/db";
import {eq, and, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {getOrganizationProjectDatabases} from "@/lib/services";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
import {capitalizeFirstLetter} from "@/utils/text";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
import {ChannelPoliciesModal} from "@/components/wrappers/dashboard/database/channels-policy/policy-modal";
import {HardDrive, Megaphone} from "lucide-react";
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import {DatabaseContent} from "@/components/wrappers/dashboard/projects/database/database-content";
@@ -106,60 +98,22 @@ export default async function RoutePage(props: PageParams<{
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(dbItem.name)}
</div>
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<RetentionPolicySheet database={dbItem}/>
<CronButton database={dbItem}/>
<ChannelPoliciesModal
database={dbItem}
kind={"notification"}
icon={<Megaphone/>}
channels={activeOrganizationChannels}
organizationId={organization.id}
/>
<ChannelPoliciesModal
database={dbItem}
icon={<HardDrive/>}
kind={"storage"}
channels={activeOrganizationStorageChannels}
organizationId={organization.id}
/>
<ImportModal database={dbItem}/>
</div>
<div className="flex items-center gap-2">
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
</div>
</div>
)}
</PageTitle>
</div>
{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} availableBackups={availableBackups}
totalBackups={totalBackups}/>
<BackupModalProvider>
<DatabaseContent
activeMember={activeMember}
settings={settings}
database={dbItem}
isAlreadyRestore={isAlreadyRestore}
restorations={restorations}
backups={backups}
/>
</BackupModalProvider>
</PageContent>
<BackupModalProvider>
<DatabaseContent
activeMember={activeMember}
settings={settings}
database={dbItem}
isAlreadyRestore={isAlreadyRestore}
restorations={restorations}
backups={backups}
totalBackups={totalBackups}
availableBackups={availableBackups}
successRate={successRate}
organizationId={organization.id}
activeOrganizationChannels={activeOrganizationChannels}
activeOrganizationStorageChannels={activeOrganizationStorageChannels}
/>
</BackupModalProvider>
</Page>
);
}
}
-2
View File
@@ -19,7 +19,6 @@ export async function POST(
) {
try {
eventEmitter.emit('modification', {update: true});
const agentId = (await params).agentId
const body: BodyResultRestore = await request.json();
@@ -72,7 +71,6 @@ export async function POST(
details: "Restoration successfully updated"
}
eventEmitter.emit('modification', {update: true});
return Response.json(response, {status: 200})
} catch (error) {
-1
View File
@@ -58,7 +58,6 @@ export async function POST(
}))
.where(eq(drizzleDb.schemas.agent.id, agentId));
eventEmitter.emit('modification', {update: true});
const response = {
agent: {
-4
View File
@@ -1,11 +1,8 @@
import {EventEmitter} from 'events';
import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers";
import {NextResponse} from "next/server";
import {eventEmitter} from "@/features/shared/event";
// export const eventEmitter = new EventEmitter();
export async function GET(request: Request) {
const session = await auth.api.getSession({
@@ -28,7 +25,6 @@ export async function GET(request: Request) {
eventEmitter.on('modification', handleModification);
// Handle client disconnect
request.signal.addEventListener('abort', () => {
console.log('Client disconnected');
controller.close();
+17 -11
View File
@@ -6,8 +6,11 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-sans: var(--font-poppins), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-title: var(--font-poppins), var(--font-poppins), sans-serif;
--font-mono: var(--font-geist-mono);
--font-author: var(--font-author);
--font-poppins: var(--font-poppins);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -71,7 +74,7 @@
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary: oklch(0.611 0.204 42.149);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
@@ -82,7 +85,7 @@
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--ring: oklch(0.611 0.204 42.149);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
@@ -90,12 +93,12 @@
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary: oklch(0.611 0.204 42.149);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--sidebar-ring: oklch(0.611 0.204 42.149);
}
.dark {
@@ -105,8 +108,8 @@
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--primary: oklch(0.704 0.191 47.132);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
@@ -116,7 +119,7 @@
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--ring: oklch(0.704 0.191 47.132);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
@@ -124,12 +127,12 @@
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary: oklch(0.704 0.191 47.132);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--sidebar-ring: oklch(0.704 0.191 47.132);
}
@layer base {
@@ -137,6 +140,9 @@
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground font-sans;
}
h1, h2, h3, h4, h5, h6 {
@apply font-title;
}
}
+2 -2
View File
@@ -4,7 +4,7 @@ import "./globals.css";
import {Providers} from "./providers";
import {cn} from "@/lib/utils";
import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer";
import {inter} from "@/fonts/fonts";
import {author, geistMono, poppins} from "@/fonts/fonts";
const title = process.env.PROJECT_NAME ?? "App Title";
@@ -27,7 +27,7 @@ export default async function RootLayout({
<head>
<meta name="apple-mobile-web-app-title" content={title}/>
</head>
<body className={cn(inter.className, "h-full")}>
<body className={cn(poppins.variable, author.variable, geistMono.variable, "font-sans h-full")}>
<ConsoleSilencer/>
<Providers>
{children}
+2 -4
View File
@@ -1,13 +1,11 @@
{
"name": "portabase",
"version": "1.2.5-rc.13",
"version": "1.2.5-rc.15",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
"build": "next build --experimental-build-mode compile",
"start": "next start",
"lint": "next lint",
"email": "email dev --dir ./src/components/emails",
"db:generate": "drizzle-kit generate",
@@ -129,5 +127,5 @@
"typescript": "^5.8.3",
"zenstack": "2.14.2"
},
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316"
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8"
}
@@ -85,7 +85,7 @@ export const LoginForm = (props: loginFormProps) => {
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input autoComplete="email" autoFocus placeholder="exemple@portabase.io" {...field} />
<Input autoComplete="email" autoFocus placeholder="example@portabase.io" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
@@ -88,7 +88,7 @@ export const RegisterForm = (props: registerFormProps) => {
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="exemple@portabase.io" {...field} />
<Input placeholder="example@portabase.io" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
@@ -1,15 +1,15 @@
"use client"
import {Button, ButtonVariantsProps} from "@/components/ui/button";
import {useState} from "react";
import {ReactNode, 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";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
export type ButtonWithConfirmProps = {
title: string;
description: string;
button: {
button?: {
main: {
className?: string;
type?: "button" | "submit" | "reset" | undefined;
@@ -37,6 +37,11 @@ export type ButtonWithConfirmProps = {
onClick?: () => void;
};
};
children?: ReactNode;
onConfirm?: (e: React.MouseEvent) => void;
onCancel?: (e: React.MouseEvent) => void;
confirmButtonText?: string;
cancelButtonText?: string;
isPending?: boolean;
};
@@ -44,41 +49,86 @@ export type ButtonWithConfirmProps = {
export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
const [isConfirming, setIsConfirming] = useState(false);
return (
<Popover open={isConfirming} onOpenChange={!props.button.main.disabled ? setIsConfirming : undefined}>
const isLegacy = !!props.button;
const isDisabled = isLegacy ? !!props.button?.main.disabled : false;
const handleConfirm = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isLegacy) {
props.button?.confirm.onClick?.();
} else {
props.onConfirm?.(e);
}
setIsConfirming(false);
};
const handleCancel = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isLegacy) {
props.button?.cancel.onClick?.();
} else {
props.onCancel?.(e);
}
setIsConfirming(false);
};
const triggerContent = isLegacy ? (
<Button
type={props.button?.main.type}
disabled={isDisabled}
variant={props.button?.main.variant ?? "default"}
size={props.button?.main.size ?? "default"}
className={props.button?.main.className}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!isDisabled) setIsConfirming(true);
}}
>
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
{props.button?.main.icon}
{props.button?.main.text && <span>{props.button.main.text}</span>}
</Button>
) : (
<div onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsConfirming(true);
}}>
{props.children}
</div>
);
const withTooltip = isLegacy && props.button?.main.tooltipText && isDisabled ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<span
className={cn(
"inline-flex",
props.button.main.disabled ? "cursor-not-allowed" : ""
)}
role="button">
<Button
type={props.button.main.type}
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.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
{props.button.main.icon}
{props.button.main.text && <span>{props.button.main.text}</span>}
</Button>
</span>
</PopoverTrigger>
<div>{triggerContent}</div>
</TooltipTrigger>
{props.button.main.tooltipText && props.button.main.disabled && (
<TooltipContent>
<p>{props.button.main.tooltipText}</p>
</TooltipContent>
)}
<TooltipContent>
<p>{props.button?.main.tooltipText}</p>
</TooltipContent>
</Tooltip>
<PopoverContent className="w-80">
</TooltipProvider>
) : triggerContent;
return (
<Popover open={isConfirming} onOpenChange={setIsConfirming}>
<PopoverTrigger asChild>
{withTooltip}
</PopoverTrigger>
<PopoverContent
className="w-80"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
onMouseMove={(e) => e.stopPropagation()}
onMouseEnter={(e) => e.stopPropagation()}
onMouseLeave={(e) => e.stopPropagation()}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="grid gap-4">
<div className="space-y-2">
<h4 className="font-medium leading-none">{props.title}</h4>
@@ -86,34 +136,26 @@ export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
</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")}
onClick={handleConfirm}
variant={isLegacy ? (props.button?.confirm.variant ?? "default") : "default"}
size={isLegacy ? (props.button?.main.size ?? "default") : "default"}
className={cn(isLegacy ? 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>
{isLegacy && props.button?.confirm.icon}
<span>{isLegacy ? props.button?.confirm.text : (props.confirmButtonText ?? "Confirm")}</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"}
variant={isLegacy ? (props.button?.cancel.variant ?? "outline") : "outline"}
onClick={handleCancel}
className={cn(isLegacy ? props.button?.main.className : "", "w-full")}
size={isLegacy ? (props.button?.main.size ?? "default") : "default"}
>
Cancel
{isLegacy ? (props.button?.cancel.text ?? "Cancel") : (props.cancelButtonText ?? "Cancel")}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
};
};
@@ -83,7 +83,7 @@ export type ButtonWithLoadingProps = {
icon?: ReactNode;
variant?: keyof VariantButton;
className?: string;
onClick?: () => void;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
isPending?: boolean;
size?: keyof SizeButton;
} & ButtonHTMLAttributes<HTMLButtonElement>;
@@ -100,7 +100,7 @@ export const ButtonWithLoading = ({
}: ButtonWithLoadingProps) => {
return (
<Button
onClick={() => onClick?.()}
onClick={(e) => onClick?.(e)}
variant={variant}
className={className}
size={size}
@@ -26,7 +26,6 @@ export const CopyButton = (props: CopyButtonProps) => {
return (
<Button
// className={cn(buttonVariants({size: "sm"}))}
onClick={() => {
copyToClipboardWithMeta(value);
setHasCopied(true);
+35 -10
View File
@@ -1,21 +1,46 @@
import { PropsWithChildren } from "react";
import { Button } from "@/components/ui/button";
"use client";
import {PropsWithChildren, useState} from "react";
import {Button} from "@/components/ui/button";
import {Check, Copy, Terminal} from "lucide-react";
import {copyToClipboardWithMeta} from "@/components/wrappers/common/button/copy-button";
import {cn} from "@/lib/utils";
export type CodeSnippetProps = PropsWithChildren<{
code: string;
title?: string;
className?: string;
}>;
export const CodeSnippet = (props: CodeSnippetProps) => {
const [isCopied, setIsCopied] = useState(false);
const handleCopy = async () => {
await copyToClipboardWithMeta(props.code);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
};
return (
<div className="rounded-lg border bg-background">
<div className="flex items-center justify-between border-b bg-muted px-4 py-2">
<div className="text-sm font-medium">.env</div>
<Button variant="ghost" size="icon" className="hover:bg-muted/50 text-muted-foreground"></Button>
</div>
<div className="p-4 font-mono text-sm leading-6 text-foreground truncate">
<pre className="language-javascript overflow-x-auto">
<code className="mb-6">{props.code}</code>
<div className={cn("relative group rounded-md bg-muted/50 border overflow-hidden", props.className)}>
{props.title && (
<div className="flex items-center px-4 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
{props.title}
</div>
)}
<div className="flex items-center p-4">
<pre className="flex-1 overflow-x-auto font-mono text-sm leading-relaxed">
<code className="break-all whitespace-pre-wrap">{props.code}</code>
</pre>
<Button
variant="ghost"
size="icon"
className="ml-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
onClick={handleCopy}
>
{isCopied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
<span className="sr-only">Copy code</span>
</Button>
</div>
</div>
);
+1 -1
View File
@@ -23,7 +23,7 @@ export const AgentRegistrationDialog = (props: agentRegistrationDialogProps) =>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
EDGE KEY
CONNECTION KEY
</Label>
<Input id="name" value="Pedro Duarte" readOnly className="col-span-3" />
</div>
@@ -14,7 +14,7 @@ import {
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table";
import {Input} from "@/components/ui/input";
import {ReactNode, useEffect, useState} from "react";
import {ReactNode, useMemo, useState} from "react";
import {TablePagination} from "./table-pagination";
import {Checkbox} from "@/components/ui/checkbox";
import {Button} from "@/components/ui/button";
@@ -63,35 +63,38 @@ export function DataTable<TData, TValue>({
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [rowSelection, setRowSelection] = useState({});
useEffect(() => {
setRowSelection({});
}, [data]);
if (enableSelect && data.length > 0) {
const finalColumns = useMemo(() => {
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,
});
if (enableSelect && data.length > 0 && !selectColumnExists) {
return [
{
id: "select",
header: ({table}: {table: any}) => (
<Checkbox
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({row}: {row: any}) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
...columns,
];
}
}
return columns;
}, [columns, enableSelect, data.length]);
const table = useReactTable({
data,
columns,
columns: finalColumns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
@@ -99,6 +102,10 @@ export function DataTable<TData, TValue>({
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onRowSelectionChange: setRowSelection,
getRowId: (row: any) => row.id || row.uuid,
autoResetPageIndex: false,
autoResetExpanded: false,
enableRowSelection: true,
state: {
sorting,
columnFilters,
@@ -18,6 +18,7 @@ import {
ChannelOrganisationForm
} from "@/components/wrappers/dashboard/admin/channels/organization/channels-organization-form";
type ChannelAddModalProps = {
channel?: NotificationChannelWith | StorageChannelWith
organization?: OrganizationWithMembers;
@@ -34,6 +34,7 @@ import {
addNotificationChannelAction, updateNotificationChannelAction
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action";
type NotifierFormProps = {
onSuccessAction?: () => void;
organization?: OrganizationWithMembers;
@@ -54,7 +55,7 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
});
useEffect(() => {
form.reset(defaultValues ? {...defaultValues} : {});
form.reset(defaultValues ? {...defaultValues} : {enabled: true});
}, [defaultValues]);
const mutationAddNotificationChannel = useMutation({
@@ -118,7 +119,7 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
}}
>
{type.preview && (
{type.preview && (
<div
className="absolute bottom-0 right-0 overflow-hidden w-20 h-20 pointer-events-none">
<div
@@ -223,11 +224,11 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
<div className="flex justify-between mt-4">
<div>
{defaultValues && (
{kind == "storage" && (
<ChannelTestButton
kind={kind}
organizationId={organization?.id}
channel={defaultValues}
channel={form.getValues()}
/>
)}
</div>
@@ -1,18 +1,21 @@
"use client"
import {Button} from "@/components/ui/button";
import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {useMutation} from "@tanstack/react-query";
import {dispatchNotification} from "@/features/notifications/dispatch";
import {EventPayload} from "@/features/notifications/types";
import {Send, ShieldCheck} from "lucide-react";
import {toast} from "sonner";
import {Button} from "@/components/ui/button";
import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {dispatchNotification} from "@/features/notifications/dispatch";
import {EventPayload} from "@/features/notifications/types";
import {useIsMobile} from "@/hooks/use-mobile";
import {cn} from "@/lib/utils";
import {StorageChannel} from "@/db/schema/12_storage-channel";
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {ChannelKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch";
type NotifierTestChannelButtonProps = {
channel: NotificationChannel | StorageChannel;
organizationId?: string;
@@ -20,8 +23,9 @@ type NotifierTestChannelButtonProps = {
}
export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestChannelButtonProps) => {
const channelText = getChannelTextBasedOnKind(kind)
const isMobile = useIsMobile()
const mutation = useMutation({
mutationFn: async () => {
if (kind === "notification") {
@@ -30,7 +34,7 @@ export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestC
message: `We are testing channel ${channel.name}`,
level: 'info',
};
const result = await dispatchNotification(payload, undefined, channel.id, organizationId);
const result = await dispatchNotification(payload, undefined, channel, organizationId);
if (result.success) {
toast.success(result.message);
@@ -41,7 +45,7 @@ export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestC
const input: StorageInput = {
action: "ping",
};
const result = await dispatchStorage(input, undefined, channel.id);
const result = await dispatchStorage(input, undefined, undefined, channel);
if (result.success) {
toast.success("Successfully connected to storage channel");
@@ -47,7 +47,7 @@ export const NotifierSmtpForm = ({form}: NotifierSmtpFormProps) => {
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="contact@exemple.com" {...field} value={field.value ?? ""}/>
<Input placeholder="contact@example.com" {...field} value={field.value ?? ""}/>
</FormControl>
<FormMessage/>
</FormItem>
@@ -77,7 +77,7 @@ export const NotifierSmtpForm = ({form}: NotifierSmtpFormProps) => {
<FormItem>
<FormLabel>From Email</FormLabel>
<FormControl>
<Input placeholder={`"Portabase" <exemple@portabase.io>`} {...field}
<Input placeholder={`"Portabase" <example@portabase.io>`} {...field}
value={field.value ?? ""}/>
</FormControl>
<FormMessage/>
@@ -19,9 +19,22 @@ export const StorageS3Form = ({form}: StorageS3FormProps) => {
name="config.endPointUrl"
render={({field}) => (
<FormItem>
<FormLabel>Endpoind URL</FormLabel>
<FormLabel>Endpoint URL</FormLabel>
<FormControl>
<Input {...field} placeholder="s3.exemple.com"/>
<Input {...field} placeholder="s3.amazonaws.com"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.region"
render={({field}) => (
<FormItem>
<FormLabel>Region</FormLabel>
<FormControl>
<Input {...field} placeholder="us-east-1"/>
</FormControl>
<FormMessage/>
</FormItem>
@@ -110,7 +110,7 @@ export const EmailForm = (props: EmailFormProps) => {
<FormItem>
<FormLabel>From Email *</FormLabel>
<FormControl>
<Input placeholder={"exemple@portabase.com"} {...field} />
<Input placeholder={"example@portabase.com"} {...field} />
</FormControl>
<FormDescription>{"The email from where the email will be send"}</FormDescription>
<FormMessage/>
@@ -172,7 +172,7 @@ export const EmailForm = (props: EmailFormProps) => {
<FormItem>
<FormLabel>User Email *</FormLabel>
<FormControl>
<Input placeholder={"exemple@portabase.com"} {...field} />
<Input placeholder={"example@portabase.com"} {...field} />
</FormControl>
<FormDescription>{"The email server user"}</FormDescription>
<FormMessage/>
@@ -1,23 +1,88 @@
"use client";
import {PasswordInput} from "@/components/ui/password-input";
import {Input} from "@/components/ui/input";
import {Label} from "@/components/ui/label";
import {Button} from "@/components/ui/button";
import {Copy, Check} from "lucide-react";
import {useState} from "react";
import {CopyButton} from "@/components/wrappers/common/button/copy-button";
import {copyToClipboardWithMeta} from "@/components/wrappers/common/button/copy-button";
export type AgentCardKeyProps = {
edgeKey: string;
agentName: string;
};
export const AgentCardKey = ({edgeKey}: AgentCardKeyProps) => {
const [code, setCode] = useState<string>(`${edgeKey}`);
export const AgentCardKey = ({edgeKey, agentName}: AgentCardKeyProps) => {
const [isCopiedKey, setIsCopiedKey] = useState(false);
const [isCopiedCommand, setIsCopiedCommand] = useState(false);
const command = `portabase agent "${agentName}" --key ${edgeKey}`;
const handleCopy = async (text: string, setter: (v: boolean) => void) => {
await copyToClipboardWithMeta(text);
setter(true);
setTimeout(() => setter(false), 2000);
};
const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
event.target.select();
};
return (
<>
<PasswordInput
value={code}
onChange={() => {
setCode(edgeKey);
}}
/>
<CopyButton className="mt-5" value={code}/>
</>
<div className="grid gap-6 py-2">
<div className="space-y-4">
<div className="flex flex-col gap-2">
<Label className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
1. Registration Key
</Label>
<div className="flex items-center gap-2">
<Input
readOnly
value={edgeKey}
onFocus={handleFocus}
className="font-mono text-xs bg-muted/30 focus-visible:ring-1 cursor-pointer"
/>
<Button
variant="outline"
size="icon"
onClick={() => handleCopy(edgeKey, setIsCopiedKey)}
className="shrink-0"
type="button"
>
{isCopiedKey ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
Use this key for manual configuration of your agent.
</p>
</div>
<div className="flex flex-col gap-2 pt-2">
<Label className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
2. Automatic Setup (CLI)
</Label>
<div className="flex items-center gap-2">
<Input
readOnly
value={command}
onFocus={handleFocus}
className="font-mono text-xs bg-muted/30 focus-visible:ring-1 cursor-pointer"
/>
<Button
variant="outline"
size="icon"
onClick={() => handleCopy(command, setIsCopiedCommand)}
className="shrink-0"
type="button"
>
{isCopiedCommand ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
Run this command on your server to automatically register the agent.
</p>
</div>
</div>
</div>
);
};
@@ -4,76 +4,109 @@ import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {Server} from "lucide-react";
import {formatDateLastContact} from "@/utils/date-formatting";
import {AgentCardKey} from "@/components/wrappers/dashboard/agent/agent-card-key/agent-card-key";
import {AgentWithDatabases} from "@/db/schema/08_agent";
import {useQuery} from "@tanstack/react-query";
import {getAgentAction} from "@/features/agents/agents.action";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import {Separator} from "@/components/ui/separator";
import {Badge} from "@/components/ui/badge";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {AgentDatabaseCard} from "@/components/wrappers/dashboard/agent/agent-database-card";
import {AgentWithDatabases} from "@/db/schema/08_agent";
import {eventUpdate} from "@/types/events";
import {useAutoRefresh} from "@/hooks/use-auto-refresh";
type AgentContentPageProps = {
edgeKey: string;
agent: AgentWithDatabases
}
export const AgentContentPage = ({edgeKey, agent}: AgentContentPageProps) => {
export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPageProps) => {
useAutoRefresh({
poll: {
enabled: true,
intervalMs: 5000,
const {data} = useQuery({
queryKey: ["agent-data", initialAgent.id],
queryFn: async () => {
const result = await getAgentAction(initialAgent.id);
return result?.data;
},
sse: {
enabled: true,
url: "/api/events",
eventName: "modification",
shouldRefresh: (data) => {
const update = data as eventUpdate;
return Boolean(update?.update);
},
initialData: {
data: initialAgent
},
staleTime: 0,
gcTime: 0,
refetchInterval: 5000,
});
const agent = data?.data ?? initialAgent;
return (
<>
<div className="space-y-10">
<div className="flex flex-col sm:flex-row sm:justify-between gap-6 ">
<Card className="w-full sm:w-auto flex-1">
<Card className="w-full sm:w-auto flex-1 border-none shadow-none bg-muted/30">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Databases</CardTitle>
<Server className="h-4 w-4 text-muted-foreground"/>
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Databases</CardTitle>
<Server className="h-4 w-4 text-muted-foreground opacity-50"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{agent.databases.length}</div>
<p className="text-xs text-muted-foreground">Databases linked to this agent</p>
<div className="text-3xl font-bold tracking-tight">{agent.databases.length}</div>
<p className="text-xs text-muted-foreground mt-1">Linked resources</p>
</CardContent>
</Card>
<Card className="w-full sm:w-auto flex-1">
<Card className="w-full sm:w-auto flex-1 border-none shadow-none bg-muted/30">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Last contact</CardTitle>
<Server className="h-4 w-4 text-muted-foreground"/>
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Last contact</CardTitle>
<Server className="h-4 w-4 text-muted-foreground opacity-50"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatDateLastContact(agent.lastContact)}</div>
<p className="text-xs text-muted-foreground">Last contact with agent</p>
<div className="text-3xl font-bold tracking-tight">{formatDateLastContact(agent.lastContact)}</div>
<p className="text-xs text-muted-foreground mt-1">Status heartbeat</p>
</CardContent>
</Card>
</div>
<Card className="w-full sm:w-auto flex-1 ">
<CardHeader className="font-bold text-xl">
Edge Key
</CardHeader>
<CardContent>
<AgentCardKey
edgeKey={edgeKey}
/>
</CardContent>
</Card>
<CardsWithPagination cardsPerPage={4} numberOfColumns={2} data={agent.databases}
cardItem={AgentDatabaseCard}/>
</>
<div className="space-y-6">
<Accordion type="single" collapsible defaultValue={!agent.lastContact ? "registration" : undefined}>
<AccordionItem value="registration" className="border rounded-xl px-6 bg-card shadow-sm overflow-hidden transition-all duration-300 data-[state=open]:ring-1 data-[state=open]:ring-primary/20">
<AccordionTrigger className="hover:no-underline py-4 group">
<div className="flex items-center gap-3">
<span className="text-xl font-bold tracking-tight">Registration & Setup</span>
{!agent.lastContact && (
<Badge variant="outline" className="bg-orange-500/10 text-orange-600 border-orange-500/20 animate-pulse">
Action Required
</Badge>
)}
</div>
</AccordionTrigger>
<AccordionContent className="pb-6 pt-2 border-t border-dashed">
<AgentCardKey
edgeKey={edgeKey}
agentName={agent.name}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
<div className="space-y-6">
<div className="flex items-center justify-between px-1">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Managed Databases</h2>
<p className="text-sm text-muted-foreground">
Resources currently connected to this agent.
</p>
</div>
</div>
<Separator className="opacity-50" />
<CardsWithPagination
cardsPerPage={4}
numberOfColumns={2}
data={agent.databases}
cardItem={AgentDatabaseCard}
/>
</div>
</div>
)
}
@@ -0,0 +1,79 @@
"use client"
import {ColumnDef} from "@tanstack/react-table"
import {Database} from "@/db/schema/07_database"
import Image from "next/image"
import {ConnectionIndicator} from "@/components/wrappers/common/connection-indicator"
import {formatDateLastContact} from "@/utils/date-formatting"
import Link from "next/link"
import {Button} from "@/components/ui/button"
import {ChevronRight} from "lucide-react"
export const agentDatabaseColumns: ColumnDef<Database>[] = [
{
accessorKey: "dbms",
header: "Type",
cell: ({row}) => (
<div className="flex items-center justify-center w-8 h-8 p-1.5 bg-muted/50 rounded-lg border border-border/50">
<Image
src={`/images/${row.original.dbms}.png`}
alt={row.original.dbms}
width={20}
height={20}
className="object-contain"
/>
</div>
)
},
{
accessorKey: "name",
header: "Name",
cell: ({row}) => (
<div className="flex flex-col">
<span className="font-bold text-sm">{row.original.name}</span>
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[150px]">
{row.original.agentDatabaseId}
</span>
</div>
)
},
{
accessorKey: "lastContact",
header: "Last Contact",
cell: ({row}) => (
<div className="flex flex-col">
<span className="text-xs">
{formatDateLastContact(row.original.lastContact)}
</span>
</div>
)
},
{
id: "status",
header: "Status",
cell: ({row}) => (
<div className="flex justify-center w-full">
<ConnectionIndicator date={row.original.lastContact} />
</div>
)
},
{
id: "actions",
header: () => null,
cell: ({row}) => {
const href = row.original.projectId
? `/dashboard/projects/${row.original.projectId}/database/${row.original.id}`
: "#";
return (
<div className="flex justify-end">
<Button variant="ghost" size="icon" asChild className="hover:bg-accent group">
<Link href={href}>
<ChevronRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-transform group-hover:translate-x-0.5" />
</Link>
</Button>
</div>
)
}
}
]
@@ -1,7 +1,7 @@
"use client";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, DialogClose } from "@/components/ui/dialog";
import { generateEdgeKey } from "@/utils/edge_key";
import { PropsWithChildren } from "react";
@@ -16,26 +16,29 @@ export type agentRegistrationDialogProps = PropsWithChildren<{
export function AgentModalKey(props: agentRegistrationDialogProps) {
const edge_key = generateEdgeKey(getServerUrl(), props.agent.id);
const code = `EDGE_KEY = ${edge_key}`;
const command = `portabase agent "${props.agent.name}" --key ${edge_key}`;
return (
<Dialog>
<DialogTrigger asChild>{props.children}</DialogTrigger>
<DialogContent className="sm:max-w-[425px] w-full">
<DialogHeader>
<DialogTitle>Agent Edge Key</DialogTitle>
<DialogTitle>Agent Connection</DialogTitle>
</DialogHeader>
<div className="sm:max-w-[375px] w-full">
<div className="sm:max-w-[375px] w-full space-y-4">
<CodeSnippet
code={code}
// className="w-full overflow-x-auto break-words"
title="Installation Command"
code={command}
/>
<CodeSnippet
title="Agent Key (Manual)"
code={edge_key}
/>
</div>
<DialogFooter>
<div className="flex items-center justify-between w-full">
<CopyButton value={code} />
<Button type="submit">Save changes</Button>
</div>
<DialogClose asChild>
<Button type="button" variant="outline">Close</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1,8 +1,6 @@
"use client";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {toast} from "sonner";
import {backupButtonAction} from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
@@ -16,7 +14,7 @@ export type BackupButtonProps = {
};
export const BackupButton = (props: BackupButtonProps) => {
const router = useRouter();
const queryClient = useQueryClient();
const isMobile = useIsMobile()
const mutation = useMutation({
@@ -24,7 +22,7 @@ export const BackupButton = (props: BackupButtonProps) => {
const backup = await backupButtonAction(databaseId);
if (backup?.data?.success) {
toast.success(backup.data.actionSuccess?.message || "Backup created successfully!");
router.refresh();
queryClient.invalidateQueries({queryKey: ["database-data", props.databaseId]});
} else {
toast.error(backup?.serverError || "Failed to create backup.");
}
@@ -33,7 +33,7 @@ export function DatabaseActionsCell({backup, activeMember, isAlreadyRestore}: Da
<div className={cn("flex items-center space-x-2")}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Button variant="ghost" size="icon" type="button" onClick={(e) => e.stopPropagation()}>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="w-4 h-4"/>
</Button>
@@ -1,7 +1,7 @@
"use client"
import {Backup, BackupWith, Restoration} from "@/db/schema/07_database";
import React from "react";
import {Swiper, SwiperSlide} from "swiper/react";
//@ts-ignore
import "swiper/css";
import "swiper/css/pagination";
import {Pagination, Mousewheel} from "swiper/modules";
@@ -12,7 +12,7 @@ import {
BackupActionsType
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.schema";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {useMutation} from "@tanstack/react-query";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {BackupStorageWith} from "@/db/schema/14_storage-backup";
import {TooltipProvider} from "@/components/ui/tooltip";
import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
@@ -29,8 +29,7 @@ import {SafeActionResult} from "next-safe-action";
import {ServerActionResult} from "@/types/action-type";
import {ZodString} from "zod";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
import {AlertCircleIcon, Trash2} from "lucide-react";
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
import {AlertCircleIcon} from "lucide-react";
type BackupActionsFormProps = {
backup: BackupWith;
@@ -42,6 +41,7 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
const filteredBackupStorages = backup.storages?.filter((storage) => storage.deletedAt === null) ?? []
const isMobile = useIsMobile();
const {closeModal} = useBackupModal();
const queryClient = useQueryClient();
const form = useZodForm({
schema: BackupActionsSchema,
@@ -74,8 +74,8 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
if (action === "download") {
const url = inner.value
if (typeof url === "string") {
window.open(url, "_self");
@@ -91,6 +91,7 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
} else {
if (action === "delete") {
toast.success("Backup deleted successfully.")
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
closeModal()
} else {
toast.error(inner?.actionError?.message ?? "An error occurred.");
@@ -111,6 +112,7 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
closeModal()
} else {
toast.error(inner?.actionError?.message);
@@ -130,10 +132,7 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
}}
>
{filteredBackupStorages.length > 0 ?
<FormField
control={form.control}
name="backupStorageId"
@@ -221,45 +220,15 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
<div className="flex flex-row items-center gap-x-4 w-full">
{action === "delete" && (
// <ButtonWithLoading
// type="button"
// variant="destructive"
// onClick={() => mutationDeleteEntireBackup.mutateAsync()}
// isPending={mutationDeleteEntireBackup.isPending}
// disabled={mutationDeleteEntireBackup.isPending}
// >
// Delete entire backup
// </ButtonWithLoading>
<ButtonWithConfirm
title={"Delete entire backup"}
description={"Are you sure you want to delete this entire backup?"}
button={{
main: {
type: "button",
variant: "destructive",
text: "Delete entire backup",
},
confirm: {
className: "w-full",
text: "Delete",
icon: <Trash2/>,
variant: "destructive",
onClick: async () => {
mutationDeleteEntireBackup.mutateAsync()
},
},
cancel: {
className: "w-full",
text: "Cancel",
icon: <Trash2/>,
variant: "outline",
},
}}
<ButtonWithLoading
type="button"
variant="destructive"
onClick={() => mutationDeleteEntireBackup.mutateAsync()}
isPending={mutationDeleteEntireBackup.isPending}
/>
disabled={mutationDeleteEntireBackup.isPending}
>
Delete entire backup
</ButtonWithLoading>
)}
{filteredBackupStorages.length > 0 && (
@@ -58,9 +58,7 @@ export const downloadBackupAction = userAction.schema(
};
const result = await dispatchStorage(input, undefined, backupStorage.storageChannelId);
console.log(result);
return {
success: result.success,
value: result.url,
@@ -0,0 +1,58 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {BackupWith, Restoration} from "@/db/schema/07_database";
export const getDatabaseDataAction = userAction
.schema(
z.object({
databaseId: z.string(),
})
)
.action(async ({parsedInput}) => {
const {databaseId} = parsedInput;
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, databaseId),
with: {
project: true,
}
});
const backups = await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, databaseId),
with: {
restorations: true,
storages: {
with: {
storageChannel: true
}
}
},
orderBy: (b, {desc}) => [desc(b.createdAt)],
}) as BackupWith[];
const restorations = await db.query.restoration.findMany({
where: eq(drizzleDb.schemas.restoration.databaseId, databaseId),
orderBy: (r, {desc}) => [desc(r.createdAt)],
}) as Restoration[];
const totalBackups = backups.length;
const availableBackups = backups.filter(b => !b.deletedAt).length;
const successfulBackups = backups.filter(b => b.status === "success").length;
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
return {
database,
backups,
restorations,
stats: {
totalBackups,
availableBackups,
successRate
}
};
});
@@ -2,7 +2,6 @@
import {createContext, useContext, useState, ReactNode} from "react";
import {BackupWith} from "@/db/schema/07_database";
import {useRouter} from "next/navigation";
export type DatabaseActionKind = "restore" | "download" | "delete";
@@ -32,7 +31,6 @@ const BackupModalContext = createContext<BackupModalContextType | undefined>(und
export const BackupModalProvider = ({children}: { children: ReactNode }) => {
const [open, setOpen] = useState(false);
const router = useRouter();
const [action, setAction] = useState<DatabaseActionKind | null>(null);
const [backup, setBackup] = useState<BackupWith | null>(null);
@@ -46,7 +44,6 @@ export const BackupModalProvider = ({children}: { children: ReactNode }) => {
setOpen(false);
setAction(null);
setBackup(null);
router.refresh()
};
return (
@@ -8,9 +8,8 @@ import {Button} from "@/components/ui/button";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select";
import {useMutation} from "@tanstack/react-query";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {Switch} from "@/components/ui/switch";
import {Card} from "@/components/ui/card";
import Link from "next/link";
@@ -48,7 +47,7 @@ export const ChannelPoliciesForm = ({
onSuccess,
kind
}: ChannelPoliciesFormProps) => {
const router = useRouter();
const queryClient = useQueryClient();
const isMobile = useIsMobile();
const channelText = getChannelTextBasedOnKind(kind);
@@ -104,10 +103,6 @@ export const ChannelPoliciesForm = ({
return existing &&
(existing.eventKinds !== policy.eventKinds || existing.enabled !== policy.enabled);
});
const promises = kind === "notification"
? [
policiesToAdd.length > 0 ? await createAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToAdd}) : null,
@@ -132,7 +127,10 @@ export const ChannelPoliciesForm = ({
if (failedActions.length > 0) throw new Error(failedActions[0].data.actionError?.message || "One or more operations failed");
return {success: true};
},
onSuccess: () => { toast.success("Policies saved successfully"); router.refresh(); },
onSuccess: () => {
toast.success("Policies saved successfully");
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
},
onError: (error: any) => { toast.error(error.message || "Failed to save policies"); },
});
@@ -37,7 +37,6 @@ export const ChannelPoliciesModal = ({icon, kind, database, channels, organizati
const channelsIds = channelsFiltered
.map(channel => channel.id);
console.log(channelsIds);
const activeAlertPolicies = database.alertPolicies?.filter((policy) => channelsIds.includes(policy.notificationChannelId));
const activeStoragePolicies = database.storagePolicies?.filter((policy) => channelsIds.includes(policy.storageChannelId));
@@ -7,9 +7,8 @@ import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQueryClient } 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/07_database";
@@ -18,7 +17,7 @@ export type CronButtonProps = {
};
export const CronButton = (props: CronButtonProps) => {
const router = useRouter();
const queryClient = useQueryClient();
const [isSwitched, setIsSwitched] = useState(props.database.backupPolicy !== null);
const [open, setOpen] = useState(false);
@@ -27,7 +26,7 @@ export const CronButton = (props: CronButtonProps) => {
updateDatabaseBackupPolicyAction({ databaseId: props.database.id, backupPolicy: value }),
onSuccess: () => {
toast.success(`Method updated successfully.`);
router.refresh();
queryClient.invalidateQueries({ queryKey: ["database-data", props.database.id] });
},
onError: () => {
toast.error(`An error occurred while updating backup method.`);
@@ -1,8 +1,7 @@
import {AdvancedCronSelect} from "./advanced-cron-select";
import {updateDatabaseBackupPolicyAction} from "@/components/wrappers/dashboard/database/cron-button/cron.action";
import {useMutation} from "@tanstack/react-query";
import {useMutation, useQueryClient} 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";
@@ -15,14 +14,14 @@ export type CronInputProps = {
export const CronInput = ({database, onSuccess}: CronInputProps) => {
const [cron, setCron] = useState<string>(database.backupPolicy ?? "* * * * *");
const router = useRouter();
const queryClient = useQueryClient();
const updateBackupPolicy = useMutation({
mutationFn: (value: string) => updateDatabaseBackupPolicyAction({databaseId: database.id, backupPolicy: value}),
onSuccess: () => {
toast.success(`Cron updated successfully.`);
onSuccess?.()
router.refresh();
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
},
onError: () => {
toast.error(`An error occurred while updating cron value.`);
@@ -1,10 +1,9 @@
"use client";
import {DropZoneFile} from "@/components/wrappers/common/dropzone/dropzone-file";
import {useMutation} from "@tanstack/react-query";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {useState} from "react";
import {Loader2} from "lucide-react";
import {useRouter} from "next/navigation";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {toast} from "sonner";
import {uploadBackupAction} from "@/components/wrappers/dashboard/database/import/upload-backup.action";
@@ -16,7 +15,7 @@ type UploadRetentionZoneProps = {
};
export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZoneProps) => {
const router = useRouter();
const queryClient = useQueryClient();
const [isProcessing, setIsProcessing] = useState(false);
const [file, setFile] = useState<File | null>(null);
@@ -37,7 +36,7 @@ export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZ
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
onSuccessAction?.()
router.refresh();
queryClient.invalidateQueries({queryKey: ["database-data", databaseId]});
} else {
toast.error(inner?.actionError?.message);
}
@@ -45,7 +44,7 @@ export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZ
console.error(err);
toast.error("An error occurred while upload in the backup");
} finally {
router.refresh();
queryClient.invalidateQueries({queryKey: ["database-data", databaseId]});
setIsProcessing(false);
}
},
@@ -10,11 +10,10 @@ import {
useZodForm
} from "@/components/ui/form";
import {RetentionSettings, RetentionSettingsSchema} from "./backup-retention-settings.schema";
import {useMutation} from "@tanstack/react-query";
import {useMutation, useQueryClient} 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";
@@ -28,7 +27,7 @@ export type BackupRetentionSettingsFormProps = {
};
export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRetentionSettingsFormProps) => {
const router = useRouter();
const queryClient = useQueryClient();
const defaultValuesFormatted: RetentionSettings = {
type: defaultValues?.type,
@@ -55,7 +54,7 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet
}),
onSuccess: () => {
toast.success("Retention policy updated successfully.");
router.refresh();
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
},
onError: () => {
toast.error("An error occurred while updating retention policy.");
@@ -49,7 +49,7 @@ export function BackupRetentionSettings({database}: BackupRetentionSettingsProps
yearly: 3,
},
})
const router = useRouter()
const queryClient = useQueryClient()
const updateRetentionPolicy = useMutation({
mutationFn: async (payload: RetentionSettings) => await updateOrCreateBackupRetentionPolicyAction({
@@ -58,7 +58,7 @@ export function BackupRetentionSettings({database}: BackupRetentionSettingsProps
}),
onSuccess: () => {
toast.success("Retention policy updated successfully.")
router.refresh()
queryClient.invalidateQueries({queryKey: ["database-data", database.id]})
},
onError: () => {
toast.error("An error occurred while updating retention policy.")
@@ -75,7 +75,7 @@ export function OrganizationCombobox() {
>
<div className={cn(
"flex aspect-square size-8 items-center justify-center rounded-lg text-white shadow-sm transition-transform duration-200",
activeOrganization?.logo ? "bg-transparent" : "bg-orange-500",
activeOrganization?.logo ? "bg-transparent" : "bg-primary",
isOpen && "scale-105"
)}>
{activeOrganization?.logo ? (
@@ -109,33 +109,33 @@ export function OrganizationCombobox() {
className={cn(
"group gap-2 p-1 cursor-pointer rounded-lg mb-1 last:mb-0 transition-colors",
isActive
? "bg-orange-500/10 text-orange-600 dark:text-orange-400 border border-orange-500/20"
? "bg-primary/10 text-primary border border-primary/20"
: "focus:bg-accent hover:bg-accent/50 border border-transparent"
)}>
<div className={cn(
"flex size-9 items-center justify-center rounded-md border shadow-sm transition-all group-hover:shadow-md",
org.logo ? "bg-transparent border-transparent" : "",
isActive && !org.logo ? "bg-orange-500 text-white border-orange-500/30" : "bg-muted/50 border-border"
isActive && !org.logo ? "bg-primary text-primary-foreground border-primary/30" : "bg-muted/50 border-border"
)}>
{org.logo ? (
<img src={org.logo} alt={org.name} className="size-9 rounded-md object-cover"/>
) : (
<Building2 className={cn(
"size-5",
isActive ? "text-white" : "text-muted-foreground"
isActive ? "text-primary-foreground" : "text-muted-foreground"
)}/>
)}
</div>
<div className="flex flex-col gap-0.5">
<span className={cn(
"text-sm max-w-42.5 truncate font-medium leading-none",
isActive ? "text-orange-600 dark:text-orange-400" : ""
isActive ? "text-primary" : ""
)}>{org.name}</span>
</div>
{isActive && (
<div
className="ml-auto flex size-5 items-center justify-center rounded-full bg-orange-500 shadow-sm">
<Check className="size-3 text-white" strokeWidth={3}/>
className="ml-auto flex size-5 items-center justify-center rounded-full bg-primary shadow-sm">
<Check className="size-3 text-primary-foreground" strokeWidth={3}/>
</div>
)}
</DropdownMenuItem>
@@ -1,6 +1,5 @@
"use client";
import {useEffect} from "react";
import {Button} from "@/components/ui/button";
import {Input} from "@/components/ui/input";
import {AlertCircle, Loader2} from "lucide-react";
@@ -22,18 +21,6 @@ export function ProfileAccount({user}: ProfileAccountProps) {
const router = useRouter();
useEffect(() => {
let interval: NodeJS.Timeout;
interval = setInterval(async () => {
router.refresh();
}, 5000);
return () => {
if (interval) clearInterval(interval);
};
});
const emailForm = useZodForm({
schema: EmailSchema,
defaultValues: {
@@ -8,11 +8,11 @@ import {DataTable} from "@/components/wrappers/common/table/data-table";
import {useMemo, useState} from "react";
import {Backup, BackupWith, DatabaseWith} from "@/db/schema/07_database";
import {Setting} from "@/db/schema/01_setting";
import {useMutation} from "@tanstack/react-query";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {MemberWithUser} from "@/db/schema/03_organization";
import {deleteBackupAction} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action";
import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm";
type DatabaseBackupListProps = {
@@ -32,7 +32,12 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
]
const [selectedFilters, setSelectedFilters] = useState<FilterItem[]>([items[1]]);
const router = useRouter();
const [isActionsOpen, setIsActionsOpen] = useState(false);
const queryClient = useQueryClient();
const columns = useMemo(() => {
return backupColumns(props.isAlreadyRestore, props.settings, props.database, props.activeMember);
}, [props.isAlreadyRestore, props.activeMember.id, props.activeMember.role]);
const filteredBackups = useMemo(() => {
if (!props.backups) return [];
@@ -98,7 +103,7 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
toast.error(result.message);
}
});
router.refresh();
queryClient.invalidateQueries({queryKey: ["database-data", props.database.id]});
},
});
@@ -107,7 +112,7 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
return (
<DataTable
enableSelect={!isMember}
columns={backupColumns(props.isAlreadyRestore, props.settings, props.database, props.activeMember)}
columns={columns}
data={filteredBackups}
enablePagination
selectedActions={(rows) => (
@@ -115,28 +120,44 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
<div className="flex justify-start md:justify-between gap-3 md:gap-0 items-center w-full ml-0">
<div className="flex gap-2">
{!isMember && (
<DropdownMenu>
<DropdownMenu open={isActionsOpen} onOpenChange={setIsActionsOpen}>
<DropdownMenuTrigger asChild>
<ButtonWithLoading
variant="outline"
onClick={() => {
onClick={(e) => {
e.stopPropagation();
setIsActionsOpen(true);
}}
disabled={rows.length === 0 || mutationDeleteBackups.isPending}
icon={<MoreHorizontal/>}
isPending={mutationDeleteBackups.isPending}
size="sm"
type="button"
>Actions</ButtonWithLoading>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={async () => {
await mutationDeleteBackups.mutateAsync(rows)
<ButtonWithConfirm
onConfirm={() => {
const backupsToDelete = rows.map(row => row
).filter(backup => backup.deletedAt == null)
if (backupsToDelete.length === 0) {
toast.error("No available backup selected for deletion.");
return;
}
mutationDeleteBackups.mutate(backupsToDelete);
setIsActionsOpen(false);
}}
className="text-red-600 focus:text-red-700"
onCancel={() => setIsActionsOpen(false)}
title="Delete backups?"
description="Are you sure you want to delete the selected backups? This action cannot be undone."
confirmButtonText="Yes, delete"
cancelButtonText="Cancel"
>
<Trash2 className="w-4 h-4 mr-2"/>
Delete Selected
</DropdownMenuItem>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Trash2 className="me-2 h-4 w-4 text-red-600"/>
<span className="text-red-600">Delete Selected</span>
</DropdownMenuItem>
</ButtonWithConfirm>
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -5,6 +5,17 @@ import {Setting} from "@/db/schema/01_setting";
import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database";
import {MemberWithUser} from "@/db/schema/03_organization";
import {useBackupModal} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
import {useQuery, useQueryClient} from "@tanstack/react-query";
import {getDatabaseDataAction} from "@/components/wrappers/dashboard/database/backup/actions/get-data.action";
import {PageContent, PageDescription, PageTitle} from "@/features/layout/page";
import {capitalizeFirstLetter} from "@/utils/text";
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
import {ChannelPoliciesModal} from "@/components/wrappers/dashboard/database/channels-policy/policy-modal";
import {HardDrive, Megaphone} from "lucide-react";
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
export type DatabaseContentProps = {
@@ -13,27 +24,114 @@ export type DatabaseContentProps = {
restorations: Restoration[],
isAlreadyRestore: boolean,
database: DatabaseWith,
activeMember: MemberWithUser
activeMember: MemberWithUser,
totalBackups: number,
availableBackups: number,
successRate: number | null,
organizationId: string,
activeOrganizationChannels: any[],
activeOrganizationStorageChannels: any[]
}
export const DatabaseContent = ({
settings,
backups,
activeMember,
isAlreadyRestore,
restorations,
database
}: DatabaseContentProps) => {
export const DatabaseContent = (props: DatabaseContentProps) => {
const {} = useBackupModal();
const {data} = useQuery({
queryKey: ["database-data", props.database.id],
queryFn: async () => {
const result = await getDatabaseDataAction({databaseId: props.database.id});
return result?.data;
},
initialData: {
database: {
...props.database,
project: props.database.project ?? null,
},
backups: props.backups,
restorations: props.restorations,
stats: {
totalBackups: props.totalBackups,
availableBackups: props.availableBackups,
successRate: props.successRate
}
},
staleTime: 0,
gcTime: 0,
refetchInterval: 5000,
});
const database = data?.database ?? props.database;
const backups = data?.backups ?? props.backups;
const restorations = data?.restorations ?? props.restorations;
const stats = data?.stats ?? {
totalBackups: props.totalBackups,
availableBackups: props.availableBackups,
successRate: props.successRate
};
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
const isMember = props.activeMember.role === "member";
return (
<>
<DatabaseBackupActionsModal/>
<DatabaseTabs activeMember={activeMember} settings={settings} database={database}
isAlreadyRestore={isAlreadyRestore}
backups={backups}
restorations={restorations}/>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(database.name)}
</div>
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<RetentionPolicySheet database={database}/>
<CronButton database={database}/>
<ChannelPoliciesModal
database={database}
kind={"notification"}
icon={<Megaphone/>}
channels={props.activeOrganizationChannels}
organizationId={props.organizationId}
/>
<ChannelPoliciesModal
database={database}
icon={<HardDrive/>}
kind={"storage"}
channels={props.activeOrganizationStorageChannels}
organizationId={props.organizationId}
/>
<ImportModal database={database}/>
</div>
<div className="flex items-center gap-2">
<BackupButton disable={isAlreadyBackup} databaseId={database.id}/>
</div>
</div>
)}
</PageTitle>
</div>
{database.description && (
<PageDescription className="mt-5 sm:mt-0">{database.description}</PageDescription>
)}
<PageContent className="flex flex-col w-full h-full">
<DatabaseKpi
successRate={stats.successRate}
database={database}
availableBackups={stats.availableBackups}
totalBackups={stats.totalBackups}
/>
<DatabaseBackupActionsModal/>
<DatabaseTabs
activeMember={props.activeMember}
settings={props.settings}
database={database}
isAlreadyRestore={isAlreadyRestore}
backups={backups}
restorations={restorations}
/>
</PageContent>
</>
)
}
@@ -11,7 +11,6 @@ export type DatabaseKpiPro = {
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">
@@ -42,7 +41,7 @@ export const DatabaseKpi = (props: DatabaseKpiPro) => {
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{props.successRate ? `${props.successRate.toFixed(0)} %` : "Unavailable"}
{typeof props.successRate === 'number' ? `${props.successRate.toFixed(0)} %` : "Unavailable"}
</div>
<p className="text-xs text-muted-foreground">Backup success rate</p>
</CardContent>
@@ -5,21 +5,28 @@ import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger
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 {useMutation, useQueryClient} from "@tanstack/react-query";
import {deleteRestoreAction} from "@/features/dashboard/restore/restore.action";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {MemberWithUser} from "@/db/schema/03_organization";
import {useMemo, useState} from "react";
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
type DatabaseRestoreListProps = {
isAlreadyRestore: boolean;
restorations: Restoration[];
activeMember: MemberWithUser
activeMember: MemberWithUser;
databaseId: string;
}
export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
const router = useRouter();
const queryClient = useQueryClient();
const [isActionsOpen, setIsActionsOpen] = useState(false);
const columns = useMemo(() => {
return restoreColumns(props.isAlreadyRestore, props.activeMember);
}, [props.isAlreadyRestore, props.activeMember.id, props.activeMember.role]);
const mutationDeleteRestorations = useMutation({
mutationFn: async (restorations: Restoration[]) => {
@@ -46,7 +53,7 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
toast.error(result.message);
}
});
router.refresh();
queryClient.invalidateQueries({queryKey: ["database-data", props.databaseId]});
},
});
const isMember = props.activeMember.role === "member";
@@ -55,35 +62,48 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
return (
<DataTable
enableSelect={!isMember}
columns={restoreColumns(props.isAlreadyRestore, props.activeMember)}
columns={columns}
data={props.restorations}
enablePagination
selectedActions={(rows) => (
<>
{!isMember && (
<DropdownMenu>
<DropdownMenu open={isActionsOpen} onOpenChange={setIsActionsOpen}>
<DropdownMenuTrigger asChild>
<ButtonWithLoading
variant="outline"
onClick={() => {
onClick={(e) => {
e.stopPropagation();
setIsActionsOpen(true);
}}
disabled={rows.length === 0 || mutationDeleteRestorations.isPending}
icon={<MoreHorizontal/>}
isPending={mutationDeleteRestorations.isPending}
size="sm"
type="button"
>Actions</ButtonWithLoading>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={async () => {
await mutationDeleteRestorations.mutateAsync(rows)
<ButtonWithConfirm
onConfirm={() => {
mutationDeleteRestorations.mutate(rows)
setIsActionsOpen(false);
}}
disabled={props.isAlreadyRestore}
className="text-red-600 focus:text-red-700"
onCancel={() => setIsActionsOpen(false)}
title="Delete restorations?"
description="Are you sure you want to delete the selected restorations? This action cannot be undone."
confirmButtonText="Yes, delete"
cancelButtonText="Cancel"
>
<Trash2 className="w-4 h-4 mr-2"/>
Delete Selected
</DropdownMenuItem>
<DropdownMenuItem
disabled={props.isAlreadyRestore}
onSelect={(e) => e.preventDefault()}
className="text-red-600 focus:text-red-700"
>
<Trash2 className="w-4 h-4 mr-2"/>
Delete Selected
</DropdownMenuItem>
</ButtonWithConfirm>
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -3,7 +3,6 @@
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 {BackupWith, 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";
@@ -25,21 +24,6 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => {
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) {
router.refresh();
}
});
return () => {
eventSource.close();
};
}, []);
useEffect(() => {
const newTab = searchParams.get("tab") ?? "backup";
setTab(newTab);
@@ -69,6 +53,7 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => {
isAlreadyRestore={props.isAlreadyRestore}
restorations={props.restorations}
activeMember={props.activeMember}
databaseId={props.database.id}
/>
</TabsContent>
</Tabs>
@@ -18,11 +18,11 @@ export const ProjectCard = (props: projectCardProps) => {
return (
<Link
href={`/dashboard/projects/${project.id}`}
className="group block transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
className="group block transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-orange-500/50 focus-visible:ring-offset-2 rounded-xl"
>
<Card className="relative h-full flex flex-col p-4 transition-all border-border/50 bg-card hover:bg-accent/50 hover:border-primary/50 group-hover:shadow-lg overflow-hidden gap-0">
<Card className="relative h-full flex flex-col p-4 transition-all duration-300 border-border/50 bg-card hover:bg-orange-500/[0.02] hover:border-orange-500/30 group-hover:shadow-md overflow-hidden gap-0">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-primary/10 text-primary group-hover:bg-primary group-hover:text-primary-foreground transition-all duration-300 shadow-inner">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-orange-500/10 text-orange-600 dark:text-orange-400 group-hover:bg-orange-500/20 transition-all duration-300">
<Folder className="w-8 h-8" />
</div>
<Badge className="text-[10px] font-medium px-2 py-1 rounded-lg bg-secondary/50 text-foreground">{dbCount} {dbCount === 1 ? "Database" : "Databases"}
@@ -31,7 +31,7 @@ export const ProjectCard = (props: projectCardProps) => {
</div>
<div className="flex flex-col gap-2 flex-1">
<h3 className="text-lg font-black text-foreground group-hover:text-primary transition-colors line-clamp-1 tracking-tight">
<h3 className="text-lg font-black text-foreground group-hover:text-orange-500 transition-colors line-clamp-1 tracking-tight">
{project.name}
</h3>
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
@@ -40,8 +40,8 @@ export const ProjectCard = (props: projectCardProps) => {
</div>
<div className="mt-4 flex items-center justify-between pt-3 border-t border-border/50">
<span className="text-[10px] font-bold uppercase tracking-widest text-primary opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-x-[-10px] group-hover:translate-x-0">View Project</span>
<div className="flex items-center gap-1 text-muted-foreground group-hover:text-primary transition-colors">
<span className="text-[10px] font-bold uppercase tracking-widest text-orange-500 opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-x-[-10px] group-hover:translate-x-0">View Project</span>
<div className="flex items-center gap-1 text-muted-foreground group-hover:text-orange-500 transition-colors">
<span className="text-[10px] font-bold uppercase tracking-widest group-hover:hidden">Details</span>
<ChevronRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform" />
</div>
@@ -83,7 +83,7 @@ export function PercentageLineChart(props: percentageLineChartProps) {
<YAxis
tickLine={false}
domain={[0, 100]}
tickFormatter={(tick) => `${tick}%`}
tickFormatter={(tick) => `${Number(tick).toFixed(0)}%`}
/>
<ChartTooltip
defaultIndex={1}
@@ -112,7 +112,7 @@ function PourcentTooltip({
<span className="h-2 w-2 rounded-full bg-[#fc6504]"/>
<span className="text-muted-foreground">Success Rate :</span>
<span className="ml-auto font-semibold">
{data.successRate} %
{data.successRate.toFixed(0)} %
</span>
</div>
</div>
+12
View File
@@ -46,3 +46,15 @@ export const updateAgentAction = userAction
data: updatedAgent,
};
});
export const getAgentAction = userAction.schema(z.string()).action(async ({parsedInput}) => {
const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, parsedInput),
with: {
databases: true
}
});
return {
data: agent,
};
});
@@ -14,6 +14,7 @@ import {AgentType} from "@/features/agents/agents.schema";
import {GearIcon} from "@radix-ui/react-icons";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
import {useState} from "react";
import {useRouter} from "next/navigation";
type AgentDialogProps = {
agent?: AgentType & { id: string };
@@ -24,6 +25,7 @@ type AgentDialogProps = {
export const AgentDialog = ({agent, typeTrigger}: AgentDialogProps) => {
const [open, setOpen] = useState(false);
const isEdit = !!agent;
const router = useRouter();
const getTrigger = () => {
switch (typeTrigger) {
@@ -52,7 +54,10 @@ export const AgentDialog = ({agent, typeTrigger}: AgentDialogProps) => {
<DialogTitle>{isEdit ? `Edit ${agent.name}` : "Create new agent"}</DialogTitle>
</DialogHeader>
<AgentForm
onSuccess={() => setOpen(false)}
onSuccess={() => {
setOpen(false)
router.refresh()
}}
defaultValues={agent}
agentId={agent?.id}
/>
@@ -13,7 +13,7 @@ import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form";
import {Button} from "@/components/ui/button";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {TooltipProvider} from "@/components/ui/tooltip";
import {AgentSchema, AgentType} from "@/features/agents/agents.schema";
import {toast} from "sonner";
@@ -27,6 +27,7 @@ export type agentFormProps = {
export const AgentForm = (props: agentFormProps) => {
const isCreate = !Boolean(props.defaultValues);
const queryClient = useQueryClient();
const form = useZodForm({
schema: AgentSchema,
@@ -46,12 +47,16 @@ export const AgentForm = (props: agentFormProps) => {
});
const data = createAgent?.data?.data;
if (createAgent?.serverError || !data) {
toast.error(createAgent?.serverError);
return;
}
toast.success(`Success ${isCreate ? "creating" : "updating"} agent`);
router.refresh();
if (!isCreate && props.agentId) {
queryClient.invalidateQueries({ queryKey: ["agent-data", props.agentId] });
}
if (props.onSuccess) {
props.onSuccess(data);
@@ -94,7 +99,7 @@ export const AgentForm = (props: agentFormProps) => {
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input placeholder="This agent is for the client exemple.com" {...field}
<Input placeholder="This agent is for the client example.com" {...field}
value={field.value ?? ""}/>
</FormControl>
<FormDescription>Enter your project agent description</FormDescription>
+11 -25
View File
@@ -1,39 +1,16 @@
"use client";
import {ColumnDef} from "@tanstack/react-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {Button} from "@/components/ui/button";
import {Download, MoreHorizontal, Trash2} from "lucide-react";
import {ReloadIcon} from "@radix-ui/react-icons";
import {
getFileUrlPresignedLocal,
getFileUrlPreSignedS3Action
} from "@/features/upload/private/upload.action";
import {useMutation} from "@tanstack/react-query";
import {createRestorationAction, deleteBackupAction} from "@/features/dashboard/restore/restore.action";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {StatusBadge} from "@/components/wrappers/common/status-badge";
import {Backup, DatabaseWith} from "@/db/schema/07_database";
import {TooltipCustom} from "@/components/wrappers/common/tooltip-custom";
import {Setting} from "@/db/schema/01_setting";
import {SafeActionResult} from "next-safe-action";
import {ZodString} from "zod";
import {ServerActionResult} from "@/types/action-type";
import {cn} from "@/lib/utils";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
import {MemberWithUser} from "@/db/schema/03_organization";
import {formatLocalizedDate} from "@/utils/date-formatting";
import {formatBytes, isImportedFilename} from "@/utils/text";
import {DatabaseActionsCell} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions-cell";
import { Badge as BadgeC } from "@/components/ui/badge";
export function backupColumns(
isAlreadyRestore: boolean,
@@ -68,7 +45,16 @@ export function backupColumns(
cell: ({row}) => {
const reference = row.original.id
const isImported = row.original.imported
return isImported ? `${reference} (imported)` : `${reference}`
return (
<div className="flex items-center space-x-2">
<span>{reference}</span>
{isImported && (
<BadgeC variant="outline" className="bg-orange-400/10 border-orange-600/50 text-orange-600">
Imported
</BadgeC>
)}
</div>
)
},
},
{
+5 -6
View File
@@ -15,13 +15,12 @@ import { ReloadIcon } from "@radix-ui/react-icons";
import { StatusBadge } from "@/components/wrappers/common/status-badge";
import {Restoration} from "@/db/schema/07_database";
import {formatLocalizedDate} from "@/utils/date-formatting";
import {useMutation} from "@tanstack/react-query";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {
deleteRestoreAction,
rerunRestorationAction
} from "@/features/dashboard/restore/restore.action";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {TooltipCustom} from "@/components/wrappers/common/tooltip-custom";
import {MemberWithUser} from "@/db/schema/03_organization";
@@ -54,7 +53,7 @@ export function restoreColumns(
cell: ({ row }) => {
const status = row.getValue("status");
const router = useRouter();
const queryClient = useQueryClient();
const rowData: Restoration = row.original;
@@ -67,7 +66,7 @@ export function restoreColumns(
if (restoration.data.success) {
// @ts-ignore
toast.success(restoration.data.actionSuccess.message);
router.refresh();
queryClient.invalidateQueries({queryKey: ["database-data", rowData.databaseId]});
} else {
// @ts-ignore
toast.error(restoration.data.actionError.message);
@@ -84,7 +83,7 @@ export function restoreColumns(
if (restoration.data.success) {
// @ts-ignore
toast.success(restoration.data.actionSuccess.message);
router.refresh();
queryClient.invalidateQueries({queryKey: ["database-data", rowData.databaseId]});
} else {
// @ts-ignore
toast.error(restoration.data.actionError.message);
@@ -109,7 +108,7 @@ export function restoreColumns(
{activeMember.role != "member" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<Button variant="ghost" className="h-8 w-8 p-0" type="button" onClick={(e) => e.stopPropagation()}>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4"/>
</Button>
@@ -4,7 +4,7 @@ import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessa
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 { useMutation, useQueryClient } from "@tanstack/react-query";
import { ProjectSchema, ProjectType } from "@/features/projects/projects.schema";
import { createProjectAction, updateProjectAction } from "@/features/projects/projects.action";
import { useRouter } from "next/navigation";
@@ -29,6 +29,7 @@ export type projectFormProps = {
export const ProjectForm = (props: projectFormProps) => {
const router = useRouter();
const queryClient = useQueryClient();
const isCreate = !Boolean(props.defaultValues);
const formatDatabasesList = (databases: DatabaseWith[]) => {
return databases.map((database) => ({
@@ -70,7 +71,13 @@ export const ProjectForm = (props: projectFormProps) => {
if (project && project.data) {
if (project.data.success) {
project.data.actionSuccess && toast.success(project.data.actionSuccess.message);
router.refresh();
if (!isCreate && values.databases) {
values.databases.forEach(dbId => {
queryClient.invalidateQueries({ queryKey: ["database-data", dbId] });
});
}
if (props.onSuccess) {
props.onSuccess(project.data.value);
} else {
@@ -78,11 +85,9 @@ export const ProjectForm = (props: projectFormProps) => {
}
} else {
project.data.actionError && toast.error(project.data.actionError.message || "Unknown error occurred.");
router.refresh();
}
} else {
toast.error("Failed to process request. No response received.");
router.refresh();
}
},
});
+9 -2
View File
@@ -1,17 +1,23 @@
"use server";
import {eq} from 'drizzle-orm';
import {Json} from "drizzle-zod";
import * as drizzleDb from '@/db';
import {db} from '@/db';
import type {StorageInput, StorageProviderKind, StorageResult,} from './types';
import {dispatchViaProvider} from "@/features/storages/providers";
import {StorageChannel} from "@/db/schema/12_storage-channel";
import {Json} from "drizzle-zod";
import {
StorageChannelFormType
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema";
export async function dispatchStorage(
input: StorageInput,
policyId?: string,
channelId?: string,
channelData?: StorageChannelFormType,
organizationId?: string
): Promise<StorageResult> {
try {
@@ -68,6 +74,8 @@ export async function dispatchStorage(
};
}
if (channelData) channel = {...channelData, config: channelData.config as Json};
if (!channel) {
return {
success: false,
@@ -90,7 +98,6 @@ export async function dispatchStorage(
channel.provider as StorageProviderKind,
channel.config,
input,
);
+2
View File
@@ -4,6 +4,7 @@ import {Readable} from "node:stream";
type S3Config = {
endPointUrl: string;
region?: string;
accessKey: string;
secretKey: string;
bucketName: string;
@@ -14,6 +15,7 @@ type S3Config = {
async function getS3Client(config: S3Config) {
return new Minio.Client({
endPoint: config.endPointUrl,
region: config.region ?? "us-east-1",
accessKey: config.accessKey,
secretKey: config.secretKey,
port: config.port ?? 443,
@@ -75,10 +75,7 @@ export async function deleteFileS3Private(fileName: string, bucketName: string)
*/
export async function deleteLocalPrivate(fileName: string) {
try {
console.log(fileName);
const filePath = path.join(process.cwd(), privateLocalDir, fileName);
console.log(`Deleted ${filePath}`);
// Delete locally
await unlink(filePath);
return {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+24 -2
View File
@@ -1,5 +1,27 @@
import {Inter} from "next/font/google";
import {Poppins} from "next/font/google";
import localFont from "next/font/local";
export const poppins = Poppins({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
variable: "--font-poppins",
});
export const author = localFont({
src: [
{
path: "./author/Author-Variable.ttf",
style: "normal",
},
{
path: "./author/Author-VariableItalic.ttf",
style: "italic",
},
],
variable: "--font-author",
});
export const inter = Inter({subsets: ["latin"]});
export const geistMono = localFont({
src: "./geist/GeistMonoVF.woff",
variable: "--font-geist-mono",
});
-68
View File
@@ -1,68 +0,0 @@
"use client";
import {useEffect, useRef} from "react";
import {useRouter} from "next/navigation";
type UseAutoRefreshOptions = {
poll?: {
intervalMs: number;
enabled?: boolean;
};
sse?: {
url: string;
eventName?: string;
enabled?: boolean;
shouldRefresh?: (data: unknown) => boolean;
};
};
export function useAutoRefresh(options: UseAutoRefreshOptions) {
const router = useRouter();
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
if (!options.poll?.enabled) return;
if (!options.poll.intervalMs) return;
const interval = setInterval(() => {
router.refresh();
}, options.poll.intervalMs);
return () => clearInterval(interval);
}, [options.poll?.enabled, options.poll?.intervalMs, router]);
useEffect(() => {
if (!options.sse?.enabled) return;
if (!options.sse.url) return;
const eventSource = new EventSource(options.sse.url);
eventSourceRef.current = eventSource;
const eventName = options.sse.eventName ?? "message";
eventSource.addEventListener(eventName, (event: MessageEvent) => {
let payload: unknown = event.data;
try {
payload = JSON.parse(event.data);
} catch {}
const shouldRefresh =
options.sse?.shouldRefresh?.(payload) ?? true;
if (shouldRefresh) {
router.refresh();
}
});
return () => {
eventSource.close();
eventSourceRef.current = null;
};
}, [
options.sse?.enabled,
options.sse?.url,
options.sse?.eventName,
router,
]);
}
-2
View File
@@ -5,7 +5,6 @@ import {enforceRetentionGFS} from "@/lib/tasks/database/retention-gsf";
import {retentionPolicy} from "@/db/schema/07_database";
import {isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {eventEmitter} from "@/features/shared/event";
export const retentionCleanTask = async () => {
@@ -52,5 +51,4 @@ export async function enforceRetention(
});
break;
}
eventEmitter.emit('modification', {update: true});
}
-3
View File
@@ -1,3 +0,0 @@
export type eventUpdate = {
update: boolean;
};