fix: project details table refresh

This commit is contained in:
Théo LAGACHE
2026-02-10 11:44:45 +01:00
parent a49b6d6573
commit 921a355ae0
48 changed files with 808 additions and 566 deletions
@@ -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,
@@ -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") {
console.log(inner.value)
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"
@@ -230,35 +229,45 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
// >
// 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>
// <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",
// },
// }}
// isPending={mutationDeleteEntireBackup.isPending}
// />
)}
@@ -57,10 +57,8 @@ export const downloadBackupAction = userAction.schema(
}
};
console.log(input)
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);
@@ -105,10 +104,6 @@ export const ChannelPoliciesForm = ({
(existing.eventKinds !== policy.eventKinds || existing.enabled !== policy.enabled);
});
console.log(policiesToUpdate);
console.log(policiesToAdd);
const promises = kind === "notification"
? [
policiesToAdd.length > 0 ? await createAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToAdd}) : null,
@@ -133,7 +128,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>