diff --git a/backend/drizzle.config.ts b/backend/drizzle.config.ts index 3ab544e..f2beb92 100644 --- a/backend/drizzle.config.ts +++ b/backend/drizzle.config.ts @@ -1,4 +1,4 @@ -import { STORAGE_DIR } from "@/consts"; +import { STORAGE_DIR } from "../consts"; import { defineConfig } from "drizzle-kit"; export default defineConfig({ diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 81568b1..55bd1ca 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -1,8 +1,8 @@ import path from "path"; import { drizzle } from "drizzle-orm/bun-sqlite"; import { Database } from "bun:sqlite"; -import { DATABASE_PATH } from "@/consts"; -import { mkdir } from "@/utility/utils"; +import { DATABASE_PATH } from "../consts"; +import { mkdir } from "../utility/utils"; import schema from "./schema"; // Create database directory if not exists diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 8a300b9..408157d 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -1,6 +1,6 @@ import fs from "fs"; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; -import { DATABASE_PATH } from "@/consts"; +import { DATABASE_PATH } from "../consts"; import db, { sqlite } from "."; import { seed } from "./seed"; diff --git a/backend/src/lib/dbms/postgres.ts b/backend/src/lib/dbms/postgres.ts index d9d2f52..1b39bf7 100644 --- a/backend/src/lib/dbms/postgres.ts +++ b/backend/src/lib/dbms/postgres.ts @@ -3,6 +3,7 @@ import type { PostgresConfig, } from "../../types/database.types"; import { exec } from "../../utility/process"; +import { urlencode } from "../../utility/utils"; import BaseDbms from "./base"; class PostgresDbms extends BaseDbms { @@ -18,7 +19,7 @@ class PostgresDbms extends BaseDbms { } async dump(dbName: string, path: string) { - return exec(["pg_dump", this.dbUrl + `/${dbName}`, "-Ftar", "-f", path]); + return exec(["pg_dump", this.dbUrl + `/${dbName}`, "-Z9", "-f", path]); } async restore(path: string) { @@ -29,7 +30,7 @@ class PostgresDbms extends BaseDbms { "-cC", "--if-exists", "--exit-on-error", - "-Ftar", + // "-Ftar", path, ]); } @@ -45,7 +46,7 @@ class PostgresDbms extends BaseDbms { private get dbUrl() { const { user, pass, host } = this.config; const port = this.config.port || 5432; - return `postgresql://${user}:${pass}@${host}:${port}`; + return `postgresql://${user}:${urlencode(pass)}@${host}:${port}`; } } diff --git a/backend/src/routers/backup.router.ts b/backend/src/routers/backup.router.ts index 1adab5c..5f5e1cf 100644 --- a/backend/src/routers/backup.router.ts +++ b/backend/src/routers/backup.router.ts @@ -2,8 +2,8 @@ import { createBackupSchema, getAllBackupQuery, restoreBackupSchema, -} from "@/schemas/backup.schema"; -import BackupService from "@/services/backup.service"; +} from "../schemas/backup.schema"; +import BackupService from "../services/backup.service"; import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; diff --git a/backend/src/routers/index.ts b/backend/src/routers/index.ts index 40cb87c..380f4b8 100644 --- a/backend/src/routers/index.ts +++ b/backend/src/routers/index.ts @@ -1,11 +1,13 @@ import { Hono } from "hono"; -import { handleError } from "@/middlewares/error-handler"; import server from "./server.router"; import backup from "./backup.router"; +import { cors } from "hono/cors"; +import { handleError } from "../middlewares/error-handler"; const routers = new Hono() // Middlewares .onError(handleError) + .use(cors()) // App health check .get("/health-check", (c) => c.text("OK")) @@ -14,4 +16,6 @@ const routers = new Hono() .route("/servers", server) .route("/backups", backup); +export type AppRouter = typeof routers; + export default routers; diff --git a/backend/src/routers/server.router.ts b/backend/src/routers/server.router.ts index 9901452..174c79e 100644 --- a/backend/src/routers/server.router.ts +++ b/backend/src/routers/server.router.ts @@ -1,9 +1,12 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; -import { checkServerSchema, createServerSchema } from "@/schemas/server.schema"; import { HTTPException } from "hono/http-exception"; -import DatabaseUtil from "@/lib/database-util"; -import ServerService from "@/services/server.service"; +import ServerService from "../services/server.service"; +import { + checkServerSchema, + createServerSchema, +} from "../schemas/server.schema"; +import DatabaseUtil from "../lib/database-util"; const serverService = new ServerService(); const router = new Hono() @@ -27,7 +30,7 @@ const router = new Hono() return c.json({ success: true, databases }); } catch (err) { throw new HTTPException(400, { - message: "Cannot connect to the database.", + message: (err as any).message || "Cannot connect to the database.", }); } }) @@ -49,7 +52,7 @@ const router = new Hono() .get("/:id", async (c) => { const { id } = c.req.param(); - const server = await serverService.getOrFail(id); + const server = await serverService.getById(id); return c.json(server); }); diff --git a/backend/src/schedulers/process-backup.ts b/backend/src/schedulers/process-backup.ts index ad11c9b..c822eb3 100644 --- a/backend/src/schedulers/process-backup.ts +++ b/backend/src/schedulers/process-backup.ts @@ -1,13 +1,13 @@ -import db from "@/db"; import fs from "fs"; import path from "path"; -import { backupModel, databaseModel } from "@/db/models"; -import DatabaseUtil from "@/lib/database-util"; -import ServerService from "@/services/server.service"; import { and, asc, eq, sql } from "drizzle-orm"; -import { BACKUP_DIR } from "@/consts"; -import { mkdir } from "@/utility/utils"; -import { hashFile } from "@/utility/hash"; +import ServerService from "../services/server.service"; +import db from "../db"; +import { backupModel, databaseModel } from "../db/models"; +import DatabaseUtil from "../lib/database-util"; +import { BACKUP_DIR } from "../consts"; +import { mkdir } from "../utility/utils"; +import { hashFile } from "../utility/hash"; let isRunning = false; const serverService = new ServerService(); @@ -19,16 +19,12 @@ const runBackup = async (task: PendingTasks[number]) => { .set({ status: "running" }) .where(eq(backupModel.id, task.id)); - const server = serverService.parse(task.server as never); + const server = serverService.parse(task.server); const dbName = task.database.name; const dbUtil = new DatabaseUtil(server.connection); if (task.type === "backup") { - const key = path.join( - server.connection.host, - dbName, - `${Date.now()}.tar` - ); + const key = path.join(server.connection.host, dbName, `${Date.now()}`); const outFile = path.join(BACKUP_DIR, key); mkdir(path.dirname(outFile)); diff --git a/backend/src/schemas/server.schema.ts b/backend/src/schemas/server.schema.ts index 190e2ce..62d6dcd 100644 --- a/backend/src/schemas/server.schema.ts +++ b/backend/src/schemas/server.schema.ts @@ -14,7 +14,7 @@ const sshSchema = z const postgresSchema = z.object({ type: z.literal("postgres"), host: z.string(), - port: z.number().optional(), + port: z.coerce.number().int().optional(), user: z.string(), pass: z.string(), }); diff --git a/backend/src/services/backup.service.ts b/backend/src/services/backup.service.ts index 75d1365..a4dfd6c 100644 --- a/backend/src/services/backup.service.ts +++ b/backend/src/services/backup.service.ts @@ -1,11 +1,11 @@ -import db from "@/db"; -import { backupModel, serverModel } from "@/db/models"; +import db from "../db"; +import { backupModel, serverModel } from "../db/models"; import type { CreateBackupSchema, GetAllBackupQuery, RestoreBackupSchema, -} from "@/schemas/backup.schema"; -import { and, desc, eq, inArray } from "drizzle-orm"; +} from "../schemas/backup.schema"; +import { and, count, desc, eq, inArray } from "drizzle-orm"; import DatabaseService from "./database.service"; import { HTTPException } from "hono/http-exception"; @@ -20,18 +20,28 @@ export default class BackupService { const page = query.page || 1; const limit = query.limit || 10; + const where = and( + serverId ? eq(backupModel.serverId, serverId) : undefined, + databaseId ? eq(backupModel.databaseId, databaseId) : undefined + ); + + const [totalRows] = await db + .select({ count: count() }) + .from(backupModel) + .where(where) + .limit(1); + const backups = await db.query.backup.findMany({ - where: (i) => - and( - serverId ? eq(i.serverId, serverId) : undefined, - databaseId ? eq(i.databaseId, databaseId) : undefined - ), + where, + with: { + database: { columns: { name: true } }, + }, orderBy: desc(serverModel.createdAt), limit, offset: (page - 1) * limit, }); - return backups; + return { count: totalRows.count, rows: backups }; } async getOrFail(id: string) { diff --git a/backend/src/services/database.service.ts b/backend/src/services/database.service.ts index ba8dfe4..729c272 100644 --- a/backend/src/services/database.service.ts +++ b/backend/src/services/database.service.ts @@ -1,5 +1,5 @@ -import db from "@/db"; -import { databaseModel } from "@/db/models"; +import db from "../db"; +import { databaseModel } from "../db/models"; import { desc, eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; diff --git a/backend/src/services/server.service.ts b/backend/src/services/server.service.ts index 2a9408b..48df92e 100644 --- a/backend/src/services/server.service.ts +++ b/backend/src/services/server.service.ts @@ -1,6 +1,6 @@ -import db from "@/db"; -import { databaseModel, serverModel, type ServerModel } from "@/db/models"; -import type { CreateServerSchema } from "@/schemas/server.schema"; +import db from "../db"; +import { databaseModel, serverModel, type ServerModel } from "../db/models"; +import type { CreateServerSchema } from "../schemas/server.schema"; import { asc, desc, eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; @@ -36,7 +36,15 @@ export default class ServerService { databases: true, }, }); - return server; + + if (!server) { + return null; + } + + const result = this.parse(server); + delete result.connection.pass; + + return result; } async create(data: CreateServerSchema) { @@ -73,7 +81,7 @@ export default class ServerService { }); } - parse(data: ServerModel) { + parse>(data: T) { const result = { ...data, connection: data.connection ? JSON.parse(data.connection) : null, diff --git a/backend/src/test.ts b/backend/src/test.ts index 2fff5dd..771f8f2 100644 --- a/backend/src/test.ts +++ b/backend/src/test.ts @@ -1,6 +1,6 @@ -import DatabaseUtil from "@/lib/database-util"; -import { DOCKER_HOST, BACKUP_DIR } from "@/consts"; -import { mkdir } from "@/utility/utils"; +import DatabaseUtil from "../lib/database-util"; +import { DOCKER_HOST, BACKUP_DIR } from "../consts"; +import { mkdir } from "../utility/utils"; import path from "path"; const main = async () => { diff --git a/backend/src/utility/utils.ts b/backend/src/utility/utils.ts index a42188d..4bef682 100644 --- a/backend/src/utility/utils.ts +++ b/backend/src/utility/utils.ts @@ -5,3 +5,7 @@ export const mkdir = (dir: string) => { fs.mkdirSync(dir, { recursive: true }); } }; + +export const urlencode = (str: string) => { + return encodeURIComponent(str); +}; diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 6138132..238655f 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -22,11 +22,6 @@ // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false, - - "baseUrl": ".", - "paths": { - "@/*": ["src/*"] - } + "noPropertyAccessFromIndexSignature": false } } diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..91f55f0 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,4 @@ +@tailwind base; + @tailwind components; + @tailwind utilities; + \ No newline at end of file diff --git a/frontend/bun.lockb b/frontend/bun.lockb new file mode 100755 index 0000000..61d63c0 Binary files /dev/null and b/frontend/bun.lockb differ diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..d077d2e --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "app/globals.css", + "baseColor": "slate", + "cssVariables": false, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 97894a9..e9fde82 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,18 +10,48 @@ "preview": "vite preview" }, "dependencies": { + "@hookform/resolvers": "^3.3.4", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tooltip": "^1.0.7", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "dayjs": "^1.11.11", + "hono": "^4.3.4", + "lucide-react": "^0.378.0", + "next-themes": "^0.3.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-data-table-component": "^7.6.2", + "react-dom": "^18.2.0", + "react-helmet": "^6.1.0", + "react-hook-form": "^7.51.4", + "react-icons": "^5.2.1", + "react-query": "^3.39.3", + "react-router-dom": "^6.23.1", + "sonner": "^1.4.41", + "tailwind-merge": "^2.3.0", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.23.8", + "zustand": "^4.5.2" }, "devDependencies": { "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", + "@types/react-helmet": "^6.1.11", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react-swc": "^3.5.0", + "autoprefixer": "^10.4.19", "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.6", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.3", "typescript": "^5.2.2", "vite": "^5.2.0" } diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index afe48ac..0000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' -import './App.css' - -function App() { - const [count, setCount] = useState(0) - - return ( - <> -
- - Vite logo - - - React logo - -
-

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- - ) -} - -export default App diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx new file mode 100644 index 0000000..0cbe0b9 --- /dev/null +++ b/frontend/src/app/App.tsx @@ -0,0 +1,22 @@ +import { QueryClientProvider } from "react-query"; +import Router from "./Router"; +import { queryClient } from "@/lib/queryClient"; +import { lazy } from "react"; +import "./global.css"; + +const Toaster = lazy(() => import("@/components/ui/sonner")); +const ConfirmDialog = lazy( + () => import("@/components/containers/confirm-dialog") +); + +const App = () => { + return ( + + + + + + ); +}; + +export default App; diff --git a/frontend/src/app/Router.tsx b/frontend/src/app/Router.tsx new file mode 100644 index 0000000..cf84158 --- /dev/null +++ b/frontend/src/app/Router.tsx @@ -0,0 +1,43 @@ +import DashboardLayout from "@/components/layouts/dashboard"; +import { Suspense, lazy, useMemo } from "react"; +import { RouterProvider, createBrowserRouter } from "react-router-dom"; + +const HomePage = lazy(() => import("@/pages/home/page")); +const ServerPage = lazy(() => import("@/pages/servers/page")); +const ViewServerPage = lazy(() => import("@/pages/servers/view/page")); + +const router = createBrowserRouter([ + { + Component: DashboardLayout, + children: [ + { index: true, Component: HomePage }, + { + path: "servers", + children: [ + { + index: true, + Component: ServerPage, + }, + { + path: ":id", + Component: ViewServerPage, + }, + ], + }, + ], + }, +]); + +const Router = () => { + const routerData = useMemo(() => { + return router; + }, []); + + return ( + + + + ); +}; + +export default Router; diff --git a/frontend/src/app/global.css b/frontend/src/app/global.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/frontend/src/app/global.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/frontend/src/components/containers/confirm-dialog.tsx b/frontend/src/components/containers/confirm-dialog.tsx new file mode 100644 index 0000000..17e38ab --- /dev/null +++ b/frontend/src/components/containers/confirm-dialog.tsx @@ -0,0 +1,49 @@ +import { createDisclosureStore } from "@/lib/disclosure"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from "../ui/dialog"; +import Button from "../ui/button"; + +type ConfirmDialogData = { + onConfirm: () => void; + title?: string; + description?: string; +}; + +// eslint-disable-next-line react-refresh/only-export-components +export const confirmDlg = createDisclosureStore({ + onConfirm: () => {}, +}); + +const ConfirmDialog = () => { + const { isOpen, data } = confirmDlg.useState(); + + return ( + + + {data?.title || "Are you sure?"} + {data?.description} + + + + + + + + ); +}; + +export default ConfirmDialog; diff --git a/frontend/src/components/containers/sidebar.tsx b/frontend/src/components/containers/sidebar.tsx new file mode 100644 index 0000000..90899f1 --- /dev/null +++ b/frontend/src/components/containers/sidebar.tsx @@ -0,0 +1,40 @@ +import { + IoCogOutline, + IoLogOutOutline, + IoPieChartOutline, + IoServerOutline, +} from "react-icons/io5"; +import Nav from "../ui/nav-item"; +import Button from "../ui/button"; +import { cn } from "@/lib/utils"; + +const Sidebar = () => { + const isOpen = false; + + return ( +
+
+

Serep

+
+ +
+ ); +}; + +export default Sidebar; diff --git a/frontend/src/components/layouts/dashboard.tsx b/frontend/src/components/layouts/dashboard.tsx new file mode 100644 index 0000000..859a18b --- /dev/null +++ b/frontend/src/components/layouts/dashboard.tsx @@ -0,0 +1,18 @@ +import { Outlet } from "react-router-dom"; +import Sidebar from "../containers/sidebar"; +import { Suspense } from "react"; + +const DashboardLayout = () => { + return ( +
+ +
+ + + +
+
+ ); +}; + +export default DashboardLayout; diff --git a/frontend/src/components/ui/alert.tsx b/frontend/src/components/ui/alert.tsx new file mode 100644 index 0000000..4af1d07 --- /dev/null +++ b/frontend/src/components/ui/alert.tsx @@ -0,0 +1,78 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border border-slate-200 p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-slate-950 dark:border-slate-800 dark:[&>svg]:text-slate-50", + { + variants: { + variant: { + default: "bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50", + destructive: + "border-red-500/50 text-red-500 dark:border-red-500 [&>svg]:text-red-500 dark:border-red-900/50 dark:text-red-900 dark:dark:border-red-900 dark:[&>svg]:text-red-900", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +type AlertProps = React.HTMLAttributes & + VariantProps; + +const Alert = React.forwardRef( + ({ className, variant, ...props }, ref) => ( +
+ ) +); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +const ErrorAlert = React.forwardRef< + HTMLDivElement, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Omit & { error?: any } +>(({ error, ...props }, ref) => { + if (!error?.message) { + return null; + } + + return ( + + {error?.message} + + ); +}); +ErrorAlert.displayName = "ErrorAlert"; + +export { Alert, AlertTitle, AlertDescription, ErrorAlert }; diff --git a/frontend/src/components/ui/back-button.tsx b/frontend/src/components/ui/back-button.tsx new file mode 100644 index 0000000..597c6ad --- /dev/null +++ b/frontend/src/components/ui/back-button.tsx @@ -0,0 +1,22 @@ +import Button from "./button"; +import { IoChevronBack } from "react-icons/io5"; + +type BackButtonProps = { + to: string; +}; + +const BackButton = ({ to }: BackButtonProps) => { + return ( + + ); +}; + +export default BackButton; diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..dec20ea --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,84 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { Loader2 } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { IconType } from "react-icons"; +import { Link } from "react-router-dom"; + +const buttonVariants = cva( + "inline-flex gap-1 items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-primary-950 dark:focus-visible:ring-primary-300", + { + variants: { + variant: { + default: + "bg-primary-500 text-white hover:bg-primary-500/90 dark:bg-primary-50 dark:text-gray-900 dark:hover:bg-primary-50/90", + destructive: + "bg-red-500 text-primary-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-primary-50 dark:hover:bg-red-900/90", + outline: + "border border-primary-200 bg-white hover:bg-primary-100 hover:text-primary-900 dark:border-primary-800 dark:bg-primary-950 dark:hover:bg-primary-800 dark:hover:text-primary-50", + secondary: + "bg-primary-100 text-primary-900 hover:bg-primary-100/80 dark:bg-primary-800 dark:text-primary-50 dark:hover:bg-primary-800/80", + ghost: + "hover:bg-primary-100 hover:text-primary-900 dark:hover:bg-primary-800 dark:hover:text-primary-50", + link: "text-primary-900 underline-offset-4 hover:underline dark:text-primary-50", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + icon?: IconType; + isLoading?: boolean; + href?: string; +} + +const Button = React.forwardRef( + ( + { + className, + variant, + size, + type = "button", + href, + icon: Icon, + children, + isLoading, + disabled, + ...props + }, + ref + ) => { + const Comp = href ? Link : "button"; + return ( + + {Icon && !isLoading ? : null} + {isLoading && } + {children} + + ); + } +); +Button.displayName = "Button"; + +export default Button; diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..3594640 --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/lib/utils"; +import React from "react"; + +const Card = ({ + className, + ...props +}: React.ComponentPropsWithoutRef<"div">) => { + return ( +
+ ); +}; + +export default Card; diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..0830223 --- /dev/null +++ b/frontend/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { Check } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/frontend/src/components/ui/data-table.tsx b/frontend/src/components/ui/data-table.tsx new file mode 100644 index 0000000..29a5e0d --- /dev/null +++ b/frontend/src/components/ui/data-table.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import DataTableComponent, { TableColumn } from "react-data-table-component"; + +type DataTableProps = React.ComponentPropsWithoutRef; + +const DataTable = ({ ...props }: DataTableProps) => { + return ( +
+ +
+ ); +}; + +export type { TableColumn }; +export default DataTable; diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..b1240f6 --- /dev/null +++ b/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,132 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogBody = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogBody.displayName = "DialogBody"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogBody, + DialogDescription, +}; diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..d1ef990 --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,198 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/frontend/src/components/ui/form.tsx b/frontend/src/components/ui/form.tsx new file mode 100644 index 0000000..d41a678 --- /dev/null +++ b/frontend/src/components/ui/form.tsx @@ -0,0 +1,93 @@ +import FormContext from "@/context/form-context"; +import { cn } from "@/lib/utils"; +import React, { useId } from "react"; +import { + Controller, + ControllerFieldState, + ControllerRenderProps, + FieldPath, + FieldValues, + UseFormReturn, + UseFormStateReturn, +} from "react-hook-form"; + +type FormProps = + React.ComponentPropsWithoutRef<"form"> & { + form: UseFormReturn; + }; + +const Form = ({ form, ...props }: FormProps) => { + return ( + +
+ + ); +}; + +type FormControlRenderFn< + T extends FieldValues, + FieldName extends FieldPath +> = ({ + field, + fieldState, + formState, +}: { + field: ControllerRenderProps; + fieldState: ControllerFieldState; + formState: UseFormStateReturn; + id: string; +}) => React.ReactElement; + +export type FormControlProps< + TValues extends FieldValues, + TName extends FieldPath = FieldPath +> = { + form: UseFormReturn; + name: TName; + render: FormControlRenderFn; + className?: string; + label?: string; +}; + +export const FormControl = ({ + form, + name, + render, + className, + label, +}: FormControlProps) => { + const fieldId = useId(); + + return ( + { + const { fieldState } = props; + + return ( +
+ {label != null && ( + + )} + + {render({ ...props, id: fieldId })} + + {fieldState.error != null && ( +

+ {fieldState.error.message} +

+ )} +
+ ); + }} + /> + ); +}; + +export default Form; diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..890c156 --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,51 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import { FieldPath, FieldValues, UseFormReturn } from "react-hook-form"; +import { FormControl } from "./form"; + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + } +); +Input.displayName = "Input"; + +type InputFieldProps = Omit & { + form: UseFormReturn; + name: FieldPath; + label?: string; +}; + +const InputField = ({ + form, + name, + label, + className, + ...props +}: InputFieldProps) => ( + } + /> +); +InputField.displayName = "InputField"; + +export { InputField }; +export default Input; diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..7c96064 --- /dev/null +++ b/frontend/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +); + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/frontend/src/components/ui/nav-item.tsx b/frontend/src/components/ui/nav-item.tsx new file mode 100644 index 0000000..b283579 --- /dev/null +++ b/frontend/src/components/ui/nav-item.tsx @@ -0,0 +1,35 @@ +import { cn } from "@/lib/utils"; +import type { IconType } from "react-icons"; +import { Link, useLocation } from "react-router-dom"; + +type NavProps = { + path: string; + title: string; + exact?: boolean; + icon: IconType; +}; + +const Nav = ({ path, title, exact, icon: Icon }: NavProps) => { + const { pathname } = useLocation(); + const isActive = exact ? pathname === path : pathname.startsWith(path); + + return ( + + {Icon ? ( +
+ +
+ ) : null} + {title} + + ); +}; +export default Nav; diff --git a/frontend/src/components/ui/page-title.tsx b/frontend/src/components/ui/page-title.tsx new file mode 100644 index 0000000..26c31ca --- /dev/null +++ b/frontend/src/components/ui/page-title.tsx @@ -0,0 +1,26 @@ +import { cn } from "@/lib/utils"; +import Helmet from "react-helmet"; + +type Props = { + children?: string; + setTitle?: boolean; + className?: string; +}; + +const PageTitle = ({ children, setTitle = true, className }: Props) => { + return ( + <> + {setTitle && ( + + {children} + + )} + +

+ {children} +

+ + ); +}; + +export default PageTitle; diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx new file mode 100644 index 0000000..d628782 --- /dev/null +++ b/frontend/src/components/ui/popover.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/frontend/src/components/ui/section-title.tsx b/frontend/src/components/ui/section-title.tsx new file mode 100644 index 0000000..04d26db --- /dev/null +++ b/frontend/src/components/ui/section-title.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/lib/utils"; +import React from "react"; + +const SectionTitle = ({ + className, + ...props +}: React.ComponentPropsWithoutRef<"p">) => { + return ( +

+ ); +}; + +export default SectionTitle; diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..09ab358 --- /dev/null +++ b/frontend/src/components/ui/select.tsx @@ -0,0 +1,222 @@ +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown, ChevronUp } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { FieldPath, FieldValues, UseFormReturn } from "react-hook-form"; +import { FormControl } from "./form"; + +const SelectRoot = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1 dark:border-slate-800 dark:bg-slate-950 dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus:ring-slate-300", + className + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export type SelectOption = { + label: string; + value: string; +}; + +type SelectProps = { + id?: string; + value?: string; + placeholder?: string; + onChange?: (value: string) => void; + options?: SelectOption[] | null; + className?: string; +}; + +const Select = React.forwardRef< + React.ElementRef, + SelectProps +>(({ id, className, placeholder, value, options, onChange }, ref) => ( + + + + + + + {/* {placeholder != null && {placeholder}} */} + + {options?.map((option) => ( + + {option.label} + + ))} + + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +type SelectFieldProps = SelectProps & { + form: UseFormReturn; + name: FieldPath; + label?: string; +}; + +const SelectField = ({ + form, + name, + label, + className, + ...props +}: SelectFieldProps) => ( + ({ label: i.name, value: i.id }))} + onChange={(i) => setQuery({ databaseId: i })} + value={query.databaseId} + placeholder="Select Database" + className="min-w-[120px]" + /> +

+ + setQuery({ limit })} + onChangePage={(page) => setQuery({ page })} + /> + +
+ ); +}; + +export default BackupSection; diff --git a/frontend/src/pages/servers/view/components/databases-section.tsx b/frontend/src/pages/servers/view/components/databases-section.tsx new file mode 100644 index 0000000..c7016aa --- /dev/null +++ b/frontend/src/pages/servers/view/components/databases-section.tsx @@ -0,0 +1,25 @@ +import Card from "@/components/ui/card"; +import DataTable from "@/components/ui/data-table"; +import SectionTitle from "@/components/ui/section-title"; +import { DatabaseType, databaseColumns } from "../table"; + +type DatabaseSectionProps = { + databases: DatabaseType[]; +}; + +const DatabaseSection = ({ databases }: DatabaseSectionProps) => { + return ( +
+ Databases + + + +
+ ); +}; + +export default DatabaseSection; diff --git a/frontend/src/pages/servers/view/page.tsx b/frontend/src/pages/servers/view/page.tsx new file mode 100644 index 0000000..4687957 --- /dev/null +++ b/frontend/src/pages/servers/view/page.tsx @@ -0,0 +1,65 @@ +import BackButton from "@/components/ui/back-button"; +import PageTitle from "@/components/ui/page-title"; +import api, { parseJson } from "@/lib/api"; +import { IoServer } from "react-icons/io5"; +import { useQuery } from "react-query"; +import { useParams } from "react-router-dom"; +import ConnectionStatus, { + getConnectionLabel, +} from "../components/connection-status"; +import Card from "@/components/ui/card"; +import BackupSection from "./components/backups-section"; +import DatabaseSection from "./components/databases-section"; + +const ViewServerPage = () => { + const id = useParams().id!; + + const { data, isLoading, error } = useQuery({ + queryKey: ["servers"], + queryFn: () => api.servers[":id"].$get({ param: { id } }).then(parseJson), + }); + + const check = useQuery({ + queryKey: ["server", id], + queryFn: async () => { + return api.servers.check[":id"].$get({ param: { id } }).then(parseJson); + }, + refetchInterval: 30000, + }); + + if (isLoading || error) { + return null; + } + + return ( +
+ + Server Information + + + +
+

{data?.name}

+ + {data?.connection?.host ? ( + + {data?.connection?.host} + + ) : null} +
+ +
+ +

{getConnectionLabel(check.data?.success, check.error)}

+
+
+ +
+ + +
+
+ ); +}; + +export default ViewServerPage; diff --git a/frontend/src/pages/servers/view/table.tsx b/frontend/src/pages/servers/view/table.tsx new file mode 100644 index 0000000..72b2a0e --- /dev/null +++ b/frontend/src/pages/servers/view/table.tsx @@ -0,0 +1,176 @@ +import { TableColumn } from "@/components/ui/data-table"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import BackupButton from "../components/backup-button"; +import { copyToClipboard, formatBytes } from "@/lib/utils"; +import BackupStatus from "../components/backup-status"; +import { queryClient } from "@/lib/queryClient"; +import Button from "@/components/ui/button"; +import { IoCloudDownload, IoCopy, IoEllipsisVertical } from "react-icons/io5"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { confirmDlg } from "@/components/containers/confirm-dialog"; +import api, { parseJson } from "@/lib/api"; +import { toast } from "sonner"; + +dayjs.extend(relativeTime); + +export type DatabaseType = { + id: string; + name: string; + isActive: boolean; + createdAt: string; + serverId: string; + lastBackupAt: string | null; +}; + +export const databaseColumns: TableColumn[] = [ + { + name: "Name", + selector: (i) => i.name, + }, + { + name: "Last Backup", + selector: (i) => + i.lastBackupAt ? dayjs(i.lastBackupAt).format("YYYY-MM-DD HH:mm") : "", + cell: (i) => (i.lastBackupAt ? dayjs(i.lastBackupAt).fromNow() : "never"), + }, + { + selector: (i) => i.id, + width: "160px", + cell: (i) => ( +
+ { + queryClient.invalidateQueries(["backups/by-server", i.serverId]); + }} + /> +
+ ), + }, +]; + +export type BackupType = { + id: string; + serverId: string; + databaseId: string; + type: string; + status: string; + output: string; + key: string | null; + hash: string | null; + size: number | null; + createdAt: Date; + database?: DatabaseType; +}; + +const onRestoreBackup = async (row: BackupType) => { + try { + const res = await api.backups.restore.$post({ + json: { backupId: row.id }, + }); + await parseJson(res); + toast.success("Queueing database restore!"); + queryClient.invalidateQueries(["backups/by-server", row.serverId]); + } catch (err) { + toast.error("Failed to restore backup! " + (err as Error).message); + } +}; + +export const backupsColumns: TableColumn[] = [ + { + name: "Type", + selector: (i) => i.type, + cell: (i) => (i.type === "backup" ? "Backup" : "Restore"), + }, + { + name: "Database Name", + selector: (i) => i.database?.name || "-", + }, + { + name: "Status", + selector: (i) => i.status, + cell: (i) => , + }, + { + name: "Data", + selector: (i) => i.key || "-", + center: true, + cell: (i) => + i.key ? ( + + ) : ( + "-" + ), + }, + { + name: "SHA256", + selector: (i) => i.hash || "", + cell: (i) => + i.hash ? ( + + ) : ( + "-" + ), + }, + { + name: "Timestamp", + selector: (i) => dayjs(i.createdAt).format("YYYY-MM-DD HH:mm"), + cell: (i) => { + const diff = dayjs().diff(dayjs(i.createdAt), "days"); + return diff < 3 + ? dayjs(i.createdAt).fromNow() + : dayjs(i.createdAt).format("DD/MM/YY HH:mm"); + }, + }, + { + selector: (i) => i.id, + width: "40px", + style: { padding: 0 }, + cell: (row) => { + return ( + + + + + + { + confirmDlg.onOpen({ + title: "Restore Backup", + description: "Are you sure want to restore this backup?", + onConfirm: () => onRestoreBackup(row), + }); + }} + > + Restore + + + + ); + }, + }, +]; diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..b7a4d01 --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,50 @@ +import { Config } from "tailwindcss"; + +const config: Config = { + darkMode: ["class"], + content: ["./src/**/*.{ts,tsx}"], + prefix: "", + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + primary: { + "50": "#eef2ff", + "100": "#e0e7ff", + "200": "#c7d2fe", + "300": "#a5b4fc", + "400": "#818cf8", + "500": "#6366f1", + "600": "#4f46e5", + "700": "#4338ca", + "800": "#3730a3", + "900": "#312e81", + "950": "#1e1b4b", + }, + }, + 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")], +}; + +export default config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index a7fc6fb..7d5cc6a 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -18,7 +18,12 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@backend/*": ["../backend/src/*"], + } }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 861b04b..3e02254 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,7 +1,14 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react-swc' +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import path from "path"; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], -}) + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "@backend": path.resolve(__dirname, "../backend/src"), + }, + }, +});