شرح مختصر للتعديلات اللي عملتها
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"id": "d8b93139-50bd-4ba7-9adb-dd226f5e8c58",
|
||||||
|
"name": "universal-db-backup",
|
||||||
|
"projectId": "9742b893-75f0-453f-95e5-571c4e621456",
|
||||||
|
"ports": [
|
||||||
|
{
|
||||||
|
"expose": true,
|
||||||
|
"number": 3000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"publicAccess": {
|
||||||
|
"enabled": true,
|
||||||
|
"domain": "auto"
|
||||||
|
},
|
||||||
|
"resourceTier": "t1",
|
||||||
|
"dockerFileName": "Dockerfile"
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
/generated/prisma
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# المرحلة الأولى: مرحلة البناء (Builder Stage)
|
||||||
|
FROM node:18-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---
|
||||||
|
|
||||||
|
# المرحلة الثانية: مرحلة الإنتاج (Production Stage)
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# ✅ **التعديل: تحديث قائمة الحزم أولاً ثم تثبيت postgresql-client**
|
||||||
|
RUN apk update && apk add --no-cache postgresql-client
|
||||||
|
|
||||||
|
# نسخ الملفات النهائية من مرحلة البناء
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
COPY --from=builder /app/.next ./.next
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "e148ba20-033d-45b5-aff5-45c775a30cf7",
|
||||||
|
"dbName": "neondb",
|
||||||
|
"status": "PROCESSING",
|
||||||
|
"createdAt": "2025-08-13T20:14:11.810Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "59776333-f4df-4ba7-ac4a-fe9cdd4d0e8f",
|
||||||
|
"dbName": "neondb",
|
||||||
|
"status": "PROCESSING",
|
||||||
|
"createdAt": "2025-08-13T20:24:26.547Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "16a4bd0f-ef7a-48c2-9314-87a4d83af6bd",
|
||||||
|
"dbName": "neondb",
|
||||||
|
"status": "COMPLETED",
|
||||||
|
"createdAt": "2025-08-13T21:08:07.751Z",
|
||||||
|
"fileName": "neondb_2025-08-13T21-08-07-750Z.backup",
|
||||||
|
"downloadUrl": "https://s3.wasabisys.com/ilibrary/neondb_2025-08-13T21-08-07-750Z.backup"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "6a066ebb-46c3-42c2-aa14-2d2f8e0d9c97",
|
||||||
|
"dbName": "neondb",
|
||||||
|
"status": "COMPLETED",
|
||||||
|
"createdAt": "2025-08-13T21:17:53.330Z",
|
||||||
|
"fileName": "neondb_2025-08-13T21-17-53-330Z.dump",
|
||||||
|
"downloadUrl": "https://ilibrary.s3.wasabisys.com/neondb_2025-08-13T21-17-53-330Z.dump?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=GURLVI9UGF3UNMOMU1KR%2F20250813%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20250813T211914Z&X-Amz-Expires=3600&X-Amz-Signature=f2d88e7aa87189a8da6c3593ddeb27f9b346cd29082ccf45840ed355c7484a07&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "9a175f17-26fb-4615-8717-3d75f4e3d0b9",
|
||||||
|
"dbName": "neondb",
|
||||||
|
"status": "COMPLETED",
|
||||||
|
"createdAt": "2025-08-13T21:18:48.639Z",
|
||||||
|
"fileName": "neondb_2025-08-13T21-18-48-639Z.dump",
|
||||||
|
"downloadUrl": "https://ilibrary.s3.wasabisys.com/neondb_2025-08-13T21-18-48-639Z.dump?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=GURLVI9UGF3UNMOMU1KR%2F20250813%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20250813T211933Z&X-Amz-Expires=3600&X-Amz-Signature=f02231664cb5bc6052b3d22347c7edf42299c26172c81141da7191fcaa153219&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d34a0541-8d49-4779-bcf4-96a02fc64043",
|
||||||
|
"dbName": "neondb",
|
||||||
|
"status": "COMPLETED",
|
||||||
|
"createdAt": "2025-08-14T20:09:15.711Z",
|
||||||
|
"fileName": "neondb_2025-08-14T20-09-15-711Z.dump",
|
||||||
|
"downloadUrl": "https://ilibrary.s3.wasabisys.com/neondb_2025-08-14T20-09-15-711Z.dump?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=GURLVI9UGF3UNMOMU1KR%2F20250814%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20250814T200939Z&X-Amz-Expires=3600&X-Amz-Signature=ee8f3fee872986fe62dd6cafc35be898c6422f7a7ab5f06a495c87a2e911eab2&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "styles/globals.css",
|
||||||
|
"baseColor": "zinc",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||||
|
import { type ThemeProviderProps } from "next-themes/dist/types"
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="flex items-center justify-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "popper",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# docker-compose.yml
|
||||||
|
# نسخة معدلة ومحسّنة للعمل على المنصات السحابية
|
||||||
|
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# الخدمة الأولى: قاعدة البيانات PostgreSQL
|
||||||
|
postgresql:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: ilibrary_db_container
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ilibrarydb
|
||||||
|
POSTGRES_USER: abdyousef
|
||||||
|
POSTGRES_PASSWORD: ilibrary2025!
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
# ✅ **الجديد هنا: تحديد حدود الموارد**
|
||||||
|
# هذا يمنع قاعدة البيانات من استهلاك كل ذاكرة السيرفر
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
|
|
||||||
|
# الخدمة الثانية: تطبيق Next.js الخاص بك
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
container_name: ilibrary_app_container
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- postgresql
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: "postgresql://abdyousef:ilibrary2025!@postgresql:5432/ilibrarydb"
|
||||||
|
NEXT_PUBLIC_API_URL: "http://localhost:3000/api"
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
# ✅ **الجديد هنا: تحديد حدود الموارد للتطبيق أيضاً**
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
|
|
||||||
|
networks:
|
||||||
|
app-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [
|
||||||
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
// إنشاء نسخة واحدة من PrismaClient لتجنب إنشاء اتصالات متعددة
|
||||||
|
const globalForPrisma = global as unknown as { prisma: PrismaClient }
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ||
|
||||||
|
new PrismaClient({
|
||||||
|
log: ['query'],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||||
|
|
||||||
|
// استيراد النوع من Prisma مباشرة
|
||||||
|
export type { BackupRecord } from '@prisma/client'
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
Generated
+9662
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "universal-db-backup",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.864.0",
|
||||||
|
"@aws-sdk/lib-storage": "^3.864.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.864.0",
|
||||||
|
"@prisma/client": "^6.14.0",
|
||||||
|
"@radix-ui/react-checkbox": "^1.1.1",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
"@radix-ui/react-popover": "^1.1.1",
|
||||||
|
"@radix-ui/react-select": "^2.1.1",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"aws-sdk": "^2.1641.0",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.395.0",
|
||||||
|
"mongodb": "^6.18.0",
|
||||||
|
"mysql2": "^3.14.3",
|
||||||
|
"next": "^14.2.31",
|
||||||
|
"next-themes": "^0.3.0",
|
||||||
|
"node-cron": "^3.0.3",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"react": "^18",
|
||||||
|
"react-dom": "^18",
|
||||||
|
"sonner": "^1.5.0",
|
||||||
|
"tailwind-merge": "^2.3.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/pg": "^8.15.5",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "14.2.4",
|
||||||
|
"postcss": "^8",
|
||||||
|
"prisma": "^6.14.0",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import '@/styles/globals.css'
|
||||||
|
import type { AppProps } from 'next/app'
|
||||||
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider'
|
||||||
|
|
||||||
|
export default function App({ Component, pageProps }: AppProps) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
<Component {...pageProps} />
|
||||||
|
<Toaster />
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Html, Head, Main, NextScript } from "next/document";
|
||||||
|
|
||||||
|
export default function Document() {
|
||||||
|
return (
|
||||||
|
<Html lang="en">
|
||||||
|
<Head />
|
||||||
|
<body className="antialiased">
|
||||||
|
<Main />
|
||||||
|
<NextScript />
|
||||||
|
</body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import { Upload } from "@aws-sdk/lib-storage";
|
||||||
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||||
|
import { PassThrough } from 'stream';
|
||||||
|
|
||||||
|
// --- Helper Types and Functions for History Logging ---
|
||||||
|
type BackupStatus = 'COMPLETED' | 'FAILED' | 'PROCESSING' | 'QUEUED' | 'CANCELLED';
|
||||||
|
type BackupRecord = {
|
||||||
|
id: string;
|
||||||
|
dbName: string;
|
||||||
|
status: BackupStatus;
|
||||||
|
createdAt: string;
|
||||||
|
fileName?: string;
|
||||||
|
error?: string;
|
||||||
|
downloadUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DB_PATH = path.resolve(process.cwd(), 'backup-history.json');
|
||||||
|
|
||||||
|
const readRecords = async (): Promise<BackupRecord[]> => {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(DB_PATH)) {
|
||||||
|
await fs.promises.writeFile(DB_PATH, JSON.stringify([]), 'utf-8');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const fileContent = await fs.promises.readFile(DB_PATH, 'utf-8');
|
||||||
|
return fileContent ? JSON.parse(fileContent) : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error reading backup history:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeRecords = async (records: BackupRecord[]): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await fs.promises.writeFile(DB_PATH, JSON.stringify(records, null, 2), 'utf-8');
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error writing backup history:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendStreamMessage = (res: NextApiResponse, data: object) => {
|
||||||
|
try {
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to write to stream:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.flushHeaders();
|
||||||
|
|
||||||
|
const {
|
||||||
|
dbHost, dbPort, dbUser, dbPassword, dbName, dbRequireSsl,
|
||||||
|
s3Endpoint, s3BucketName, s3AccessKey, s3SecretKey, s3Region
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const recordId = randomUUID();
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const backupFileName = `${dbName as string}_${timestamp}.dump`;
|
||||||
|
|
||||||
|
const backupsDir = path.resolve(process.cwd(), 'backups');
|
||||||
|
const backupFilePath = path.join(backupsDir, backupFileName);
|
||||||
|
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir);
|
||||||
|
|
||||||
|
const newRecord: BackupRecord = { id: recordId, dbName: dbName as string, status: 'PROCESSING', createdAt: new Date().toISOString(), fileName: backupFileName };
|
||||||
|
const records = await readRecords();
|
||||||
|
records.push(newRecord);
|
||||||
|
await writeRecords(records);
|
||||||
|
|
||||||
|
const pgDumpCommand = 'pg_dump';
|
||||||
|
|
||||||
|
const args: string[] = [
|
||||||
|
'--format=c',
|
||||||
|
'--blobs',
|
||||||
|
'--verbose',
|
||||||
|
`--host=${dbHost}`,
|
||||||
|
`--port=${dbPort}`,
|
||||||
|
`--username=${dbUser}`,
|
||||||
|
`--dbname=${dbName}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const env: NodeJS.ProcessEnv = {
|
||||||
|
...process.env,
|
||||||
|
PGPASSWORD: dbPassword as string,
|
||||||
|
PGSSLMODE: dbRequireSsl === 'true' ? 'require' : 'prefer',
|
||||||
|
};
|
||||||
|
|
||||||
|
const backupProcess = spawn(pgDumpCommand, args, { env });
|
||||||
|
|
||||||
|
req.on('close', async () => {
|
||||||
|
console.log("Client disconnected. Terminating backup process...");
|
||||||
|
backupProcess.kill(); // Stop the pg_dump process
|
||||||
|
const finalRecords = await readRecords();
|
||||||
|
const recordIndex = finalRecords.findIndex(r => r.id === recordId);
|
||||||
|
if (recordIndex > -1 && finalRecords[recordIndex].status === 'PROCESSING') {
|
||||||
|
finalRecords[recordIndex].status = 'CANCELLED';
|
||||||
|
finalRecords[recordIndex].error = 'Process cancelled by user.';
|
||||||
|
await writeRecords(finalRecords);
|
||||||
|
}
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
const s3Client = new S3Client({
|
||||||
|
endpoint: s3Endpoint as string,
|
||||||
|
region: s3Region as string,
|
||||||
|
credentials: { accessKeyId: s3AccessKey as string, secretAccessKey: s3SecretKey as string }
|
||||||
|
});
|
||||||
|
|
||||||
|
const passThrough = new PassThrough();
|
||||||
|
passThrough.pipe(fs.createWriteStream(backupFilePath));
|
||||||
|
|
||||||
|
const s3Upload = new Upload({
|
||||||
|
client: s3Client,
|
||||||
|
params: { Bucket: s3BucketName as string, Key: backupFileName, Body: passThrough, ContentType: 'application/octet-stream' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (backupProcess.stdout) backupProcess.stdout.pipe(passThrough);
|
||||||
|
|
||||||
|
let errorOutput = '';
|
||||||
|
if (backupProcess.stderr) {
|
||||||
|
backupProcess.stderr.on('data', (data: Buffer | string) => {
|
||||||
|
errorOutput += data.toString();
|
||||||
|
sendStreamMessage(res, { message: data.toString().trim() });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await s3Upload.done();
|
||||||
|
sendStreamMessage(res, { message: "✅ S3 upload completed successfully." });
|
||||||
|
|
||||||
|
const command = new GetObjectCommand({ Bucket: s3BucketName as string, Key: backupFileName });
|
||||||
|
const signedUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
|
||||||
|
|
||||||
|
const finalRecords = await readRecords();
|
||||||
|
const recordIndex = finalRecords.findIndex(r => r.id === recordId);
|
||||||
|
if (recordIndex > -1) {
|
||||||
|
finalRecords[recordIndex].status = 'COMPLETED';
|
||||||
|
finalRecords[recordIndex].downloadUrl = signedUrl;
|
||||||
|
await writeRecords(finalRecords);
|
||||||
|
}
|
||||||
|
sendStreamMessage(res, { message: "All tasks finished.", status: 'completed' });
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
sendStreamMessage(res, { message: `❌ S3 Upload Failed: ${err.message}`, status: 'failed' });
|
||||||
|
const finalRecords = await readRecords();
|
||||||
|
const recordIndex = finalRecords.findIndex(r => r.id === recordId);
|
||||||
|
if (recordIndex > -1) {
|
||||||
|
finalRecords[recordIndex].status = 'FAILED';
|
||||||
|
finalRecords[recordIndex].error = `S3 Error: ${err.message}`;
|
||||||
|
await writeRecords(finalRecords);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
sendStreamMessage(res, { status: 'closed' });
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// Define a type for our backup records for type safety
|
||||||
|
type BackupRecord = {
|
||||||
|
id: string;
|
||||||
|
dbName: string;
|
||||||
|
status: 'COMPLETED' | 'FAILED' | 'PROCESSING' | 'QUEUED';
|
||||||
|
createdAt: string;
|
||||||
|
fileName?: string;
|
||||||
|
error?: string;
|
||||||
|
downloadUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define the path to our simple JSON database file
|
||||||
|
const DB_PATH = path.resolve(process.cwd(), 'backup-history.json');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads backup records from the JSON file.
|
||||||
|
* @returns {Promise<BackupRecord[]>} A promise that resolves to an array of backup records.
|
||||||
|
*/
|
||||||
|
const readRecords = async (): Promise<BackupRecord[]> => {
|
||||||
|
try {
|
||||||
|
// Check if the database file exists
|
||||||
|
if (!fs.existsSync(DB_PATH)) {
|
||||||
|
// If not, create it with an empty array
|
||||||
|
await fs.promises.writeFile(DB_PATH, JSON.stringify([]), 'utf-8');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// If it exists, read and parse it
|
||||||
|
const fileContent = await fs.promises.readFile(DB_PATH, 'utf-8');
|
||||||
|
return JSON.parse(fileContent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error reading backup history:", error);
|
||||||
|
// Return an empty array in case of any error to prevent crashes
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API handler for fetching backup history.
|
||||||
|
*/
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
// This endpoint only supports GET requests
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
res.setHeader('Allow', ['GET']);
|
||||||
|
return res.status(405).json({ message: `Method ${req.method} Not Allowed` });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const records = await readRecords();
|
||||||
|
// Sort records by date, newest first
|
||||||
|
const sortedRecords = records.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
return res.status(200).json(sortedRecords);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({ error: 'Failed to retrieve backup history.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// Define a type for our backup records for type safety
|
||||||
|
type BackupRecord = {
|
||||||
|
id: string;
|
||||||
|
dbName: string;
|
||||||
|
status: 'COMPLETED' | 'FAILED' | 'PROCESSING' | 'QUEUED';
|
||||||
|
createdAt: string;
|
||||||
|
fileName?: string;
|
||||||
|
error?: string;
|
||||||
|
downloadUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define the path to our simple JSON database file
|
||||||
|
// This file will be created in the root of your project
|
||||||
|
const DB_PATH = path.resolve(process.cwd(), 'backup-history.json');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads backup records from the JSON file.
|
||||||
|
* Creates the file if it doesn't exist.
|
||||||
|
* @returns {Promise<BackupRecord[]>} A promise that resolves to an array of backup records.
|
||||||
|
*/
|
||||||
|
const readRecords = async (): Promise<BackupRecord[]> => {
|
||||||
|
try {
|
||||||
|
// Check if the database file exists
|
||||||
|
if (!fs.existsSync(DB_PATH)) {
|
||||||
|
// If not, create it with an empty array
|
||||||
|
await fs.promises.writeFile(DB_PATH, JSON.stringify([]), 'utf-8');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// If it exists, read and parse it
|
||||||
|
const fileContent = await fs.promises.readFile(DB_PATH, 'utf-8');
|
||||||
|
// Handle case where file is empty
|
||||||
|
return fileContent ? JSON.parse(fileContent) : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error reading backup history:", error);
|
||||||
|
// Return an empty array in case of any error to prevent crashes
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API handler for fetching backup history.
|
||||||
|
*/
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
// This endpoint only supports GET requests
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
res.setHeader('Allow', ['GET']);
|
||||||
|
return res.status(405).json({ message: `Method ${req.method} Not Allowed` });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const records = await readRecords();
|
||||||
|
// Sort records by date, newest first, to display the latest jobs on top
|
||||||
|
const sortedRecords = records.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
return res.status(200).json(sortedRecords);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error fetching history:", error);
|
||||||
|
return res.status(500).json({ error: 'Failed to retrieve backup history.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
type Data = {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function handler(
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse<Data>,
|
||||||
|
) {
|
||||||
|
res.status(200).json({ name: "John Doe" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { Client as PgClient } from 'pg';
|
||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
import { MongoClient } from 'mongodb';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles testing the connection to a database.
|
||||||
|
* This version uses native Node.js drivers for reliability and security.
|
||||||
|
*/
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return res.status(405).json({ message: 'Method Not Allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
dbType,
|
||||||
|
dbHost,
|
||||||
|
dbPort,
|
||||||
|
dbUser,
|
||||||
|
dbPassword,
|
||||||
|
dbName,
|
||||||
|
dbRequireSsl
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!dbType || !dbHost || !dbPort || !dbUser) {
|
||||||
|
return res.status(400).json({ error: 'Missing required database credentials for testing.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (dbType) {
|
||||||
|
case 'postgresql':
|
||||||
|
const pgClient = new PgClient({
|
||||||
|
host: dbHost,
|
||||||
|
port: Number(dbPort),
|
||||||
|
user: dbUser,
|
||||||
|
password: dbPassword,
|
||||||
|
database: dbName,
|
||||||
|
ssl: dbRequireSsl ? { rejectUnauthorized: false } : false,
|
||||||
|
// ✅ **التعديل: زيادة مهلة الاتصال إلى 30 ثانية**
|
||||||
|
connectionTimeoutMillis: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pgClient.connect();
|
||||||
|
await pgClient.end();
|
||||||
|
return res.status(200).json({ message: 'PostgreSQL connection successful!' });
|
||||||
|
} catch (error: any) {
|
||||||
|
return res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'mysql':
|
||||||
|
let mysqlConnection;
|
||||||
|
try {
|
||||||
|
mysqlConnection = await mysql.createConnection({
|
||||||
|
host: dbHost,
|
||||||
|
port: Number(dbPort),
|
||||||
|
user: dbUser,
|
||||||
|
password: dbPassword,
|
||||||
|
database: dbName,
|
||||||
|
ssl: dbRequireSsl ? { rejectUnauthorized: false } : undefined,
|
||||||
|
connectTimeout: 30000, // 30 second timeout
|
||||||
|
});
|
||||||
|
await mysqlConnection.end();
|
||||||
|
return res.status(200).json({ message: 'MySQL connection successful!' });
|
||||||
|
} catch (error: any) {
|
||||||
|
// Ensure connection is closed even if it fails
|
||||||
|
if (mysqlConnection) await mysqlConnection.end();
|
||||||
|
return res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'mongodb':
|
||||||
|
const mongoURI = `mongodb://${dbUser}:${encodeURIComponent(dbPassword || '')}@${dbHost}:${dbPort}/${dbName || ''}?authSource=admin`;
|
||||||
|
const mongoClient = new MongoClient(mongoURI, {
|
||||||
|
ssl: dbRequireSsl,
|
||||||
|
serverSelectionTimeoutMS: 30000, // 30 second timeout
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await mongoClient.connect();
|
||||||
|
await mongoClient.db("admin").command({ ping: 1 });
|
||||||
|
await mongoClient.close();
|
||||||
|
return res.status(200).json({ message: 'MongoDB connection successful!' });
|
||||||
|
} catch (error: any) {
|
||||||
|
await mongoClient.close();
|
||||||
|
return res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return res.status(400).json({ error: 'Unsupported database type' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+442
@@ -0,0 +1,442 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { Loader2, CheckCircle, XCircle, Wand2, Moon, Sun, ShieldCheck, History, ArrowRight, ArrowLeft, Download, Database, Server, Cloud } from "lucide-react";
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
// It's a good practice to define types for complex objects
|
||||||
|
type BackupRecord = {
|
||||||
|
id: string;
|
||||||
|
dbName: string;
|
||||||
|
status: 'COMPLETED' | 'FAILED' | 'PROCESSING' | 'QUEUED';
|
||||||
|
createdAt: string;
|
||||||
|
fileName?: string;
|
||||||
|
error?: string;
|
||||||
|
downloadUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define a type for the form data to ensure type safety
|
||||||
|
type BackupFormData = {
|
||||||
|
dbType: string;
|
||||||
|
dbHost: string;
|
||||||
|
dbPort: string;
|
||||||
|
dbUser: string;
|
||||||
|
dbPassword: string;
|
||||||
|
dbName: string;
|
||||||
|
dbRequireSsl: boolean;
|
||||||
|
s3Endpoint: string;
|
||||||
|
s3BucketName: string;
|
||||||
|
s3AccessKey: string;
|
||||||
|
s3SecretKey: string;
|
||||||
|
s3Region: string;
|
||||||
|
cronExpression: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type View = 'form' | 'history';
|
||||||
|
type Step = 'database' | 's3' | 'schedule';
|
||||||
|
type ConnectionStatus = 'idle' | 'success' | 'error' | 'testing';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main component for the Backup System UI
|
||||||
|
*/
|
||||||
|
export default function HomePage() {
|
||||||
|
const { setTheme, theme } = useTheme();
|
||||||
|
const [view, setView] = useState<View>('form');
|
||||||
|
|
||||||
|
// State for the main backup form
|
||||||
|
const [formData, setFormData] = useState<BackupFormData>({
|
||||||
|
dbType: 'postgresql',
|
||||||
|
dbHost: '', dbPort: '5432', dbUser: '', dbPassword: '', dbName: '', dbRequireSsl: true,
|
||||||
|
s3Endpoint: '', s3BucketName: '', s3AccessKey: '', s3SecretKey: '', s3Region: 'us-east-1',
|
||||||
|
cronExpression: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// UI/UX State
|
||||||
|
const [currentStep, setCurrentStep] = useState<Step>('database');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('idle');
|
||||||
|
|
||||||
|
// State for the real-time backup process modal
|
||||||
|
const [isBackupInProgress, setIsBackupInProgress] = useState(false);
|
||||||
|
const [backupLogs, setBackupLogs] = useState<string[]>([]);
|
||||||
|
const [backupStatus, setBackupStatus] = useState<'pending' | 'completed' | 'failed'>('pending');
|
||||||
|
|
||||||
|
// --- Event Handlers ---
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
|
// Reset connection status if database details change
|
||||||
|
if (name.startsWith('db')) {
|
||||||
|
setConnectionStatus('idle');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectChange = (name: string, value: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
|
if (name === 'dbType') {
|
||||||
|
const ports: { [key: string]: string } = { postgresql: '5432' };
|
||||||
|
setFormData(prev => ({ ...prev, dbPort: ports[value] || '' }));
|
||||||
|
setConnectionStatus('idle');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckboxChange = (name: string, checked: boolean) => {
|
||||||
|
setFormData(prev => ({ ...prev, [name]: checked }));
|
||||||
|
if (name.startsWith('db')) {
|
||||||
|
setConnectionStatus('idle');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- API Calls ---
|
||||||
|
|
||||||
|
const handleTestConnection = async () => {
|
||||||
|
setConnectionStatus('testing');
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/test-connection', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
setConnectionStatus('success');
|
||||||
|
toast.success("Connection successful!");
|
||||||
|
} else {
|
||||||
|
setConnectionStatus('error');
|
||||||
|
toast.error("Connection failed.", { description: result.error || "Please check credentials and network." });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setConnectionStatus('error');
|
||||||
|
toast.error("Connection failed.", { description: error?.message || "An unknown error occurred." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// If cron expression exists, it's a schedule job, not a real-time one
|
||||||
|
if (formData.cronExpression) {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/backup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success("Success! 🎉", { description: "Backup job has been scheduled." });
|
||||||
|
setView('history');
|
||||||
|
} else {
|
||||||
|
toast.error("Uh oh! Something went wrong.", { description: result.message || result.error });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error("Failed to schedule backup.", { description: error?.message });
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Start Real-time Backup via Server-Sent Events ---
|
||||||
|
setIsBackupInProgress(true);
|
||||||
|
setBackupLogs([]);
|
||||||
|
setBackupStatus('pending');
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
// A more type-safe way to append params
|
||||||
|
Object.entries(formData).forEach(([key, value]) => {
|
||||||
|
queryParams.append(key, String(value));
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventSource = new EventSource(`/api/backup-stream?${queryParams.toString()}`);
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
setBackupLogs(prev => [...prev, "Connection to server established. Starting backup..."]);
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.message) {
|
||||||
|
setBackupLogs((prevLogs) => [...prevLogs, data.message]);
|
||||||
|
}
|
||||||
|
if (data.status) {
|
||||||
|
setBackupStatus(data.status);
|
||||||
|
if (['completed', 'failed', 'closed'].includes(data.status)) {
|
||||||
|
eventSource.close();
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
toast.success("Backup completed successfully!");
|
||||||
|
} else if (data.status === 'failed') {
|
||||||
|
toast.error("Backup failed.", { description: "Check logs for more details."});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = (err) => {
|
||||||
|
toast.error("Connection to backup stream failed.");
|
||||||
|
setBackupStatus('failed');
|
||||||
|
setBackupLogs((prev) => [...prev, "Stream connection closed unexpectedly. The server might have terminated the process."]);
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Step Navigation ---
|
||||||
|
const nextStep = () => {
|
||||||
|
if (currentStep === 'database') setCurrentStep('s3');
|
||||||
|
if (currentStep === 's3') setCurrentStep('schedule');
|
||||||
|
};
|
||||||
|
const prevStep = () => {
|
||||||
|
if (currentStep === 'schedule') setCurrentStep('s3');
|
||||||
|
if (currentStep === 's3') setCurrentStep('database');
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Render Logic ---
|
||||||
|
|
||||||
|
const renderStepContent = () => {
|
||||||
|
switch(currentStep) {
|
||||||
|
case 'database': return <DatabaseStepContent formData={formData} connectionStatus={connectionStatus} onTestConnection={handleTestConnection} onChange={handleChange} onSelectChange={(v: string) => handleSelectChange('dbType', v)} onCheckboxChange={(c: boolean) => handleCheckboxChange('dbRequireSsl', c)} />;
|
||||||
|
case 's3': return <S3StepContent formData={formData} onChange={handleChange} />;
|
||||||
|
case 'schedule': return <ScheduleStepContent formData={formData} onChange={handleChange} />;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container mx-auto flex min-h-screen flex-col items-center justify-center p-4">
|
||||||
|
<div className="absolute top-4 right-4 flex items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setView(view === 'form' ? 'history' : 'form')}>
|
||||||
|
{view === 'form' ? <History className="mr-2 h-4 w-4"/> : <Wand2 className="mr-2 h-4 w-4" />}
|
||||||
|
{view === 'form' ? 'View History' : 'New Backup'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon" onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
|
||||||
|
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
<span className="sr-only">Toggle theme</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view === 'form' ? (
|
||||||
|
<Card className="w-full max-w-2xl animate-in fade-in-50 duration-500">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-2xl"><ShieldCheck /> Secure Backup System</CardTitle>
|
||||||
|
<CardDescription>Configure and run a secure, one-time or scheduled database backup.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{renderStepContent()}
|
||||||
|
|
||||||
|
<CardFooter className="flex justify-between">
|
||||||
|
<Button type="button" variant="outline" onClick={prevStep} disabled={currentStep === 'database'}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" /> Previous
|
||||||
|
</Button>
|
||||||
|
{currentStep !== 'schedule' ? (
|
||||||
|
<Button type="button" onClick={nextStep} disabled={currentStep === 'database' && connectionStatus !== 'success'}>
|
||||||
|
Next <ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{formData.cronExpression ? 'Schedule Backup' : 'Run Backup Now'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<HistoryView />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Real-time Backup Progress Modal */}
|
||||||
|
<Dialog open={isBackupInProgress} onOpenChange={setIsBackupInProgress}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Backup in Progress...</DialogTitle>
|
||||||
|
<DialogDescription>Please keep this window open until the process is complete. Logs are streamed from the server.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="mt-4 h-64 overflow-y-auto rounded-md bg-muted p-4 font-mono text-xs selection:bg-primary selection:text-primary-foreground">
|
||||||
|
{backupLogs.map((log, index) => (<p key={index} className="whitespace-pre-wrap">{log}</p>))}
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="sm:justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{backupStatus === 'completed' && <><CheckCircle className="h-5 w-5 text-green-500" /><p className="text-sm text-green-500">Backup completed successfully!</p></>}
|
||||||
|
{backupStatus === 'failed' && <><XCircle className="h-5 w-5 text-red-500" /><p className="text-sm text-red-500">Backup failed. Check logs for details.</p></>}
|
||||||
|
{backupStatus === 'pending' && <><Loader2 className="h-5 w-5 animate-spin" /><p className="text-sm text-muted-foreground">Processing...</p></>}
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => { setIsBackupInProgress(false); if (backupStatus !== 'pending') setView('history'); }}>Close</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sub-components for Form Steps with defined Prop Types ---
|
||||||
|
|
||||||
|
type DatabaseStepProps = {
|
||||||
|
formData: BackupFormData;
|
||||||
|
connectionStatus: ConnectionStatus;
|
||||||
|
onTestConnection: () => void;
|
||||||
|
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onSelectChange: (value: string) => void;
|
||||||
|
onCheckboxChange: (checked: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DatabaseStepContent = ({ formData, connectionStatus, onTestConnection, onChange, onSelectChange, onCheckboxChange }: DatabaseStepProps) => (
|
||||||
|
<CardContent className="space-y-6 pt-6">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dbType">Database Type</Label>
|
||||||
|
<Select onValueChange={onSelectChange} defaultValue={formData.dbType}>
|
||||||
|
<SelectTrigger id="dbType"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent><SelectItem value="postgresql">PostgreSQL</SelectItem></SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2"><Label htmlFor="dbHost">Host</Label><Input id="dbHost" name="dbHost" value={formData.dbHost} onChange={onChange} placeholder="e.g., db.example.com" required /></div>
|
||||||
|
<div className="space-y-2"><Label htmlFor="dbPort">Port</Label><Input id="dbPort" name="dbPort" value={formData.dbPort} onChange={onChange} required /></div>
|
||||||
|
<div className="space-y-2"><Label htmlFor="dbUser">User</Label><Input id="dbUser" name="dbUser" value={formData.dbUser} onChange={onChange} placeholder="e.g., backup_user" required /></div>
|
||||||
|
<div className="space-y-2"><Label htmlFor="dbPassword">Password</Label><Input id="dbPassword" name="dbPassword" type="password" value={formData.dbPassword} onChange={onChange} /></div>
|
||||||
|
<div className="space-y-2"><Label htmlFor="dbName">Database Name</Label><Input id="dbName" name="dbName" value={formData.dbName} onChange={onChange} placeholder="e.g., production_db" required /></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 pt-2">
|
||||||
|
<Checkbox id="dbRequireSsl" checked={formData.dbRequireSsl} onCheckedChange={onCheckboxChange} />
|
||||||
|
<label htmlFor="dbRequireSsl" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Require SSL Connection</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end space-x-2 pt-4 border-t mt-4">
|
||||||
|
{connectionStatus === 'success' && <CheckCircle className="h-5 w-5 text-green-500" />}
|
||||||
|
{connectionStatus === 'error' && <XCircle className="h-5 w-5 text-red-500" />}
|
||||||
|
<Button type="button" variant="outline" onClick={onTestConnection} disabled={connectionStatus === 'testing'}>
|
||||||
|
{connectionStatus === 'testing' && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} Test Connection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
);
|
||||||
|
|
||||||
|
type S3StepProps = {
|
||||||
|
formData: BackupFormData;
|
||||||
|
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const S3StepContent = ({ formData, onChange }: S3StepProps) => (
|
||||||
|
<CardContent className="space-y-4 pt-6">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2 sm:col-span-2"><Label htmlFor="s3Endpoint">S3-Compatible Endpoint URL</Label><Input id="s3Endpoint" name="s3Endpoint" value={formData.s3Endpoint} onChange={onChange} placeholder="e.g., s3.us-west-2.amazonaws.com" required /></div>
|
||||||
|
<div className="space-y-2"><Label htmlFor="s3BucketName">Bucket Name</Label><Input id="s3BucketName" name="s3BucketName" value={formData.s3BucketName} onChange={onChange} required /></div>
|
||||||
|
<div className="space-y-2"><Label htmlFor="s3Region">Region</Label><Input id="s3Region" name="s3Region" value={formData.s3Region} onChange={onChange} required /></div>
|
||||||
|
<div className="space-y-2"><Label htmlFor="s3AccessKey">Access Key</Label><Input id="s3AccessKey" name="s3AccessKey" value={formData.s3AccessKey} type="password" onChange={onChange} required /></div>
|
||||||
|
<div className="space-y-2"><Label htmlFor="s3SecretKey">Secret Key</Label><Input id="s3SecretKey" name="s3SecretKey" type="password" value={formData.s3SecretKey} onChange={onChange} required /></div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
);
|
||||||
|
|
||||||
|
type ScheduleStepProps = {
|
||||||
|
formData: BackupFormData;
|
||||||
|
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ScheduleStepContent = ({ formData, onChange }: ScheduleStepProps) => (
|
||||||
|
<CardContent className="space-y-4 pt-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cronExpression">Cron Expression (Optional)</Label>
|
||||||
|
<Input id="cronExpression" name="cronExpression" placeholder="e.g., 0 2 * * * (for 2 AM daily)" value={formData.cronExpression} onChange={onChange} />
|
||||||
|
<p className="text-sm text-muted-foreground">Leave empty to run backup once immediately. Use <a href="https://crontab.guru/" target="_blank" rel="noopener noreferrer" className="text-primary underline">crontab.guru</a> to build expressions.</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to display the history of backup jobs
|
||||||
|
*/
|
||||||
|
function HistoryView() {
|
||||||
|
const [records, setRecords] = useState<BackupRecord[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchRecords = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/backups');
|
||||||
|
if(response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setRecords(data);
|
||||||
|
} else {
|
||||||
|
toast.error("Could not fetch backup history.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch backup records:", error);
|
||||||
|
toast.error("Could not fetch backup history.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchRecords();
|
||||||
|
const interval = setInterval(fetchRecords, 5000); // Refresh every 5 seconds
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-4xl animate-in fade-in-50 duration-500">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Backup History</CardTitle>
|
||||||
|
<CardDescription>A list of all scheduled and completed backup jobs. Refreshes automatically.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading && records.length === 0 ? (
|
||||||
|
<div className="flex flex-col justify-center items-center p-8 gap-4">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
<p className="text-muted-foreground">Loading history...</p>
|
||||||
|
</div>
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<p className="text-center text-muted-foreground p-8">No backup jobs found yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-md">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Database</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Created At</TableHead>
|
||||||
|
<TableHead>Filename / Error</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{records.map((record) => (
|
||||||
|
<TableRow key={record.id}>
|
||||||
|
<TableCell className="font-medium flex items-center gap-2"><Database size={16}/> {record.dbName}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={record.status === 'COMPLETED' ? 'default' : record.status === 'FAILED' ? 'destructive' : 'secondary'}>
|
||||||
|
{(record.status === 'QUEUED' || record.status === 'PROCESSING') && <Loader2 className="mr-2 h-3 w-3 animate-spin" />}
|
||||||
|
{record.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{new Date(record.createdAt).toLocaleString()}</TableCell>
|
||||||
|
<TableCell className="text-xs max-w-xs truncate" title={record.fileName || record.error}>{record.fileName || record.error}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{record.status === 'COMPLETED' && record.downloadUrl && (
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<a href={record.downloadUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Download className="mr-2 h-4 w-4"/> Download
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: ["@tailwindcss/postcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
Binary file not shown.
@@ -0,0 +1,11 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "BackupRecord" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"dbName" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"completedAt" DATETIME,
|
||||||
|
"fileName" TEXT,
|
||||||
|
"downloadUrl" TEXT,
|
||||||
|
"error" TEXT
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "sqlite"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ إضافة نوع جديد للحالة
|
||||||
|
enum BackupStatus {
|
||||||
|
QUEUED
|
||||||
|
PROCESSING
|
||||||
|
COMPLETED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
model BackupRecord {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
dbName String
|
||||||
|
status BackupStatus @default(QUEUED) // ✅ استخدام النوع الجديد
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
completedAt DateTime?
|
||||||
|
fileName String?
|
||||||
|
downloadUrl String?
|
||||||
|
error String?
|
||||||
|
|
||||||
|
// ✅ إضافة حقل جديد لتخزين إعدادات النسخ الاحتياطي
|
||||||
|
configJson String
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,63 @@
|
|||||||
|
/* 📍 الملف: styles/globals.css */
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
/* Olive Green Theme */
|
||||||
|
--background: 48 20% 95%;
|
||||||
|
--foreground: 48 10% 20%;
|
||||||
|
--card: 48 25% 98%;
|
||||||
|
--card-foreground: 48 10% 20%;
|
||||||
|
--popover: 48 25% 98%;
|
||||||
|
--popover-foreground: 48 10% 20%;
|
||||||
|
--primary: 80 44% 40%;
|
||||||
|
--primary-foreground: 60 93% 96%;
|
||||||
|
--secondary: 48 15% 90%;
|
||||||
|
--secondary-foreground: 80 44% 40%;
|
||||||
|
--muted: 48 15% 90%;
|
||||||
|
--muted-foreground: 48 10% 40%;
|
||||||
|
--accent: 48 15% 90%;
|
||||||
|
--accent-foreground: 80 44% 40%;
|
||||||
|
--destructive: 0 84% 60%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 48 15% 85%;
|
||||||
|
--input: 48 15% 85%;
|
||||||
|
--ring: 80 44% 40%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
/* Dark Theme Example */
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 212.7 26.8% 83.9%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
'./pages/**/*.{js,ts,jsx,tsx}',
|
||||||
|
'./components/**/*.{js,ts,jsx,tsx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "2rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1400px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: { height: "0" },
|
||||||
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
to: { height: "0" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Reference in New Issue
Block a user