mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
fix: project details table refresh
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
+3
-4
@@ -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.");
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user