diff --git a/backend/drizzle.config.ts b/backend/drizzle.config.ts index f2beb92..d5beb14 100644 --- a/backend/drizzle.config.ts +++ b/backend/drizzle.config.ts @@ -1,4 +1,4 @@ -import { STORAGE_DIR } from "../consts"; +import { STORAGE_DIR } from "./src/consts"; import { defineConfig } from "drizzle-kit"; export default defineConfig({ diff --git a/backend/package.json b/backend/package.json index 66fb1b0..848d22a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,8 +21,9 @@ }, "dependencies": { "@hono/zod-validator": "^0.2.1", + "dayjs": "^1.11.11", "drizzle-orm": "^0.30.10", - "hono": "^4.3.4", + "hono": "4.3.5", "nanoid": "^5.0.7", "node-schedule": "^2.1.1", "zod": "^3.23.8" diff --git a/backend/src/db/migrations/0000_square_agent_brand.sql b/backend/src/db/migrations/0000_clumsy_doorman.sql similarity index 93% rename from backend/src/db/migrations/0000_square_agent_brand.sql rename to backend/src/db/migrations/0000_clumsy_doorman.sql index c935800..8a920b1 100644 --- a/backend/src/db/migrations/0000_square_agent_brand.sql +++ b/backend/src/db/migrations/0000_clumsy_doorman.sql @@ -30,7 +30,9 @@ CREATE TABLE `servers` ( `connection` text, `ssh` text, `is_active` integer DEFAULT true NOT NULL, - `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `backup` text, + `next_backup` text ); --> statement-breakpoint CREATE TABLE `users` ( diff --git a/backend/src/db/migrations/meta/0000_snapshot.json b/backend/src/db/migrations/meta/0000_snapshot.json index 885ed06..ece672f 100644 --- a/backend/src/db/migrations/meta/0000_snapshot.json +++ b/backend/src/db/migrations/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "96dd8a39-5c64-4bb1-86de-7a81b83ed1db", + "id": "242cd56d-c814-44c6-8a5b-4f0814248f31", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "backups": { @@ -233,6 +233,20 @@ "notNull": true, "autoincrement": false, "default": "CURRENT_TIMESTAMP" + }, + "backup": { + "name": "backup", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_backup": { + "name": "next_backup", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false } }, "indexes": {}, diff --git a/backend/src/db/migrations/meta/_journal.json b/backend/src/db/migrations/meta/_journal.json index 7d2711a..8bec384 100644 --- a/backend/src/db/migrations/meta/_journal.json +++ b/backend/src/db/migrations/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1715367813285, - "tag": "0000_square_agent_brand", + "when": 1715513358120, + "tag": "0000_clumsy_doorman", "breakpoints": true } ] diff --git a/backend/src/db/models.ts b/backend/src/db/models.ts index fd95e4b..500f838 100644 --- a/backend/src/db/models.ts +++ b/backend/src/db/models.ts @@ -1,6 +1,12 @@ -import { relations, sql, type InferSelectModel } from "drizzle-orm"; +import { + relations, + sql, + type InferInsertModel, + type InferSelectModel, +} from "drizzle-orm"; import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { nanoid } from "nanoid"; +import type { ServerBackupSchema } from "../schemas/server.schema"; export const userModel = sqliteTable("users", { id: text("id") @@ -27,6 +33,8 @@ export const serverModel = sqliteTable("servers", { createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), + backup: text("backup", { mode: "json" }).$type(), + nextBackup: text("next_backup"), }); export type ServerModel = InferSelectModel; @@ -96,6 +104,9 @@ export const backupModel = sqliteTable("backups", { .default(sql`CURRENT_TIMESTAMP`), }); +export type BackupModel = InferSelectModel; +export type InsertBackupModel = InferInsertModel; + export const backupRelations = relations(backupModel, ({ one }) => ({ server: one(serverModel, { fields: [backupModel.serverId], diff --git a/backend/src/routers/server.router.ts b/backend/src/routers/server.router.ts index 174c79e..0006750 100644 --- a/backend/src/routers/server.router.ts +++ b/backend/src/routers/server.router.ts @@ -5,6 +5,7 @@ import ServerService from "../services/server.service"; import { checkServerSchema, createServerSchema, + updateServerSchema, } from "../schemas/server.schema"; import DatabaseUtil from "../lib/database-util"; @@ -54,6 +55,13 @@ const router = new Hono() const { id } = c.req.param(); const server = await serverService.getById(id); return c.json(server); + }) + + .patch("/:id", zValidator("json", updateServerSchema), async (c) => { + const server = await serverService.getOrFail(c.req.param("id")); + const data = c.req.valid("json"); + const result = await serverService.update(server, data); + return c.json(result); }); export default router; diff --git a/backend/src/schedulers/backup-scheduler.ts b/backend/src/schedulers/backup-scheduler.ts new file mode 100644 index 0000000..cf22d8c --- /dev/null +++ b/backend/src/schedulers/backup-scheduler.ts @@ -0,0 +1,55 @@ +import { and, eq, ne, gte, sql } from "drizzle-orm"; +import db from "../db"; +import { + backupModel, + databaseModel, + serverModel, + type InsertBackupModel, +} from "../db/models"; +import dayjs from "dayjs"; +import ServerService from "../services/server.service"; +import type { CreateBackupSchema } from "../schemas/backup.schema"; + +export const backupScheduler = async () => { + const serverService = new ServerService(); + const now = dayjs().format("YYYY-MM-DD HH:mm:ss"); + + const queue = await db.query.servers.findMany({ + where: and( + eq(serverModel.isActive, true), + gte( + sql`strftime('%s', ${now})`, + sql`strftime('%s', ${serverModel.nextBackup})` + ) + ), + with: { + databases: { + columns: { id: true }, + where: eq(databaseModel.isActive, true), + }, + }, + }); + + const tasks = queue.map(async (item) => { + console.log("CREATING BACKUP SCHEDULE FOR " + item.name); + + try { + const backups: InsertBackupModel[] = item.databases.map((d) => ({ + serverId: item.id, + databaseId: d.id, + type: "backup", + })); + await db.insert(backupModel).values(backups).execute(); + + const nextBackup = serverService.calculateNextBackup(item); + await db + .update(serverModel) + .set({ nextBackup }) + .where(eq(serverModel.id, item.id)); + } catch (err) { + console.error(err); + } + }); + + await Promise.all(tasks); +}; diff --git a/backend/src/schedulers/index.ts b/backend/src/schedulers/index.ts index abd646b..3f661e0 100644 --- a/backend/src/schedulers/index.ts +++ b/backend/src/schedulers/index.ts @@ -1,6 +1,9 @@ import scheduler from "node-schedule"; import { processBackup } from "./process-backup"; +import { backupScheduler } from "./backup-scheduler"; export const initScheduler = () => { scheduler.scheduleJob("*/10 * * * * *", processBackup); + // scheduler.scheduleJob("* * * * * *", backupScheduler); + backupScheduler(); }; diff --git a/backend/src/schemas/backup.schema.ts b/backend/src/schemas/backup.schema.ts index b4a31e2..ded1050 100644 --- a/backend/src/schemas/backup.schema.ts +++ b/backend/src/schemas/backup.schema.ts @@ -12,9 +12,14 @@ export const getAllBackupQuery = z export type GetAllBackupQuery = z.infer; -export const createBackupSchema = z.object({ - databaseId: z.string().nanoid(), -}); +export const createBackupSchema = z + .object({ + serverId: z.string().nanoid().optional(), + databaseId: z.string().nanoid().optional(), + }) + .refine((i) => i.serverId || i.databaseId, { + message: "Either serverId or databaseId is required.", + }); export type CreateBackupSchema = z.infer; diff --git a/backend/src/schemas/server.schema.ts b/backend/src/schemas/server.schema.ts index 62d6dcd..587a1a5 100644 --- a/backend/src/schemas/server.schema.ts +++ b/backend/src/schemas/server.schema.ts @@ -16,21 +16,49 @@ const postgresSchema = z.object({ host: z.string(), port: z.coerce.number().int().optional(), user: z.string(), - pass: z.string(), + pass: z.string().optional(), }); export const connectionSchema = z.discriminatedUnion("type", [postgresSchema]); +export const serverBackupSchema = z.object({ + compress: z.boolean(), + scheduled: z.boolean(), + every: z.coerce.number().min(1), + interval: z.enum([ + "second", + "minute", + "hour", + "day", + "week", + "month", + "year", + ]), + time: z + .string() + .regex(/^\d{2}:\d{2}$/) + .optional(), + day: z.coerce.number().min(0).max(6).optional(), + month: z.coerce.number().min(0).max(11).optional(), +}); + +export type ServerBackupSchema = z.infer; + export const createServerSchema = z.object({ name: z.string().min(1), ssh: sshSchema, connection: connectionSchema, isActive: z.boolean().optional(), databases: z.string().array().min(1), + backup: serverBackupSchema.optional().nullable(), }); export type CreateServerSchema = z.infer; +export const updateServerSchema = createServerSchema.partial(); + +export type UpdateServerSchema = z.infer; + export const checkServerSchema = z.object({ ssh: sshSchema, connection: connectionSchema, diff --git a/backend/src/services/backup.service.ts b/backend/src/services/backup.service.ts index a4dfd6c..2c829ed 100644 --- a/backend/src/services/backup.service.ts +++ b/backend/src/services/backup.service.ts @@ -1,5 +1,5 @@ import db from "../db"; -import { backupModel, serverModel } from "../db/models"; +import { backupModel, databaseModel, serverModel } from "../db/models"; import type { CreateBackupSchema, GetAllBackupQuery, @@ -8,6 +8,7 @@ import type { import { and, count, desc, eq, inArray } from "drizzle-orm"; import DatabaseService from "./database.service"; import { HTTPException } from "hono/http-exception"; +import ServerService from "./server.service"; export default class BackupService { private databaseService = new DatabaseService(); @@ -58,19 +59,48 @@ export default class BackupService { * Queue new backup */ async create(data: CreateBackupSchema) { - const database = await this.databaseService.getOrFail(data.databaseId); - await this.checkPendingBackup(database.id); + if (data.databaseId) { + const database = await this.databaseService.getOrFail(data.databaseId); + await this.checkPendingBackup(database.id); - const [result] = await db - .insert(backupModel) - .values({ + const [result] = await db + .insert(backupModel) + .values({ + type: "backup", + serverId: database.serverId, + databaseId: database.id, + }) + .returning(); + + return result; + } else if (data.serverId) { + const databases = await db.query.database.findMany({ + where: and( + eq(databaseModel.serverId, data.serverId), + eq(databaseModel.isActive, true) + ), + }); + if (!databases.length) { + throw new HTTPException(400, { + message: "No active databases found for this server.", + }); + } + + const values = databases.map((d) => ({ type: "backup", - serverId: database.serverId, - databaseId: database.id, - }) - .returning(); + serverId: d.serverId, + databaseId: d.id, + })); - return result; + const result = await db + .insert(backupModel) + .values(values as never) + .returning(); + + return result; + } + + return null; } async restore(data: RestoreBackupSchema) { diff --git a/backend/src/services/server.service.ts b/backend/src/services/server.service.ts index 48df92e..380162b 100644 --- a/backend/src/services/server.service.ts +++ b/backend/src/services/server.service.ts @@ -1,8 +1,12 @@ 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 type { + CreateServerSchema, + UpdateServerSchema, +} from "../schemas/server.schema"; +import { and, asc, desc, eq, ne } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; +import dayjs from "dayjs"; export default class ServerService { async getAll() { @@ -61,6 +65,7 @@ export default class ServerService { type: data.connection.type, connection: data.connection ? JSON.stringify(data.connection) : null, ssh: data.ssh ? JSON.stringify(data.ssh) : null, + nextBackup: this.calculateNextBackup(data as never), }; // Create server @@ -81,6 +86,47 @@ export default class ServerService { }); } + async update( + server: Awaited>, + data: UpdateServerSchema + ) { + if (data.name) { + const isExist = await db.query.servers.findFirst({ + where: and( + ne(serverModel.id, server.id), + eq(serverModel.name, data.name) + ), + }); + if (isExist) { + throw new HTTPException(400, { message: "Server name already exists" }); + } + } + + const dataValue = { + ...data, + type: data.connection?.type || server.type, + connection: data.connection + ? JSON.stringify({ + ...data.connection, + pass: data.connection.pass || server.connection?.pass, + }) + : undefined, + ssh: data.ssh ? JSON.stringify(data.ssh) : undefined, + nextBackup: data.backup + ? this.calculateNextBackup(data as never) + : undefined, + }; + + // Update server + const [result] = await db + .update(serverModel) + .set(dataValue) + .where(eq(serverModel.id, server.id)) + .returning(); + + return result; + } + parse>(data: T) { const result = { ...data, @@ -90,4 +136,44 @@ export default class ServerService { return result; } + + calculateNextBackup( + server: Pick, + from?: Date | string | null + ) { + if (!server.backup?.scheduled) { + return null; + } + + let date = dayjs(from); + const { + interval = "day", + every = 1, + time = "00:00", + day = 0, + month = 0, + } = server.backup || {}; + const [hh, mm] = time.split(":").map(Number); + + if (Number.isNaN(hh) || Number.isNaN(mm)) { + throw new Error("Invalid time format"); + } + + date = date.add(every || 1, interval); + + if (interval !== "second") { + date = date.set("second", 0).set("millisecond", 0); + } + if (["day", "week", "month", "year"].includes(interval)) { + date = date.set("hour", hh).set("minute", mm); + } + if (["week", "month"].includes(interval)) { + date = date.set("day", day); + } + if (interval === "year") { + date = date.set("month", month).set("date", 1); + } + + return date.format("YYYY-MM-DD HH:mm:ss"); + } } diff --git a/backend/src/types/database.types.ts b/backend/src/types/database.types.ts index 8fa724c..f93de9d 100644 --- a/backend/src/types/database.types.ts +++ b/backend/src/types/database.types.ts @@ -4,7 +4,7 @@ export type PostgresConfig = { type: "postgres"; host: string; user: string; - pass: string; + pass?: string; port?: number; }; diff --git a/frontend/bun.lockb b/frontend/bun.lockb index 61d63c0..a48c755 100755 Binary files a/frontend/bun.lockb and b/frontend/bun.lockb differ diff --git a/frontend/package.json b/frontend/package.json index e9fde82..7608a7f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,11 +18,12 @@ "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.0.4", "@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", + "hono": "4.3.5", "lucide-react": "^0.378.0", "next-themes": "^0.3.0", "react": "^18.2.0", diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx index 0830223..de2bc97 100644 --- a/frontend/src/components/ui/checkbox.tsx +++ b/frontend/src/components/ui/checkbox.tsx @@ -3,10 +3,17 @@ import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; import { Check } from "lucide-react"; import { cn } from "@/lib/utils"; +import { FormControl } from "./form"; +import { FieldPath, FieldValues, UseFormReturn } from "react-hook-form"; +import { Label } from "./label"; + +type CheckboxProps = React.ComponentPropsWithoutRef< + typeof CheckboxPrimitive.Root +>; const Checkbox = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef + CheckboxProps >(({ className, ...props }, ref) => ( = Omit< + CheckboxProps, + "form" +> & { + form: UseFormReturn; + name: FieldPath; + label?: string; + fieldClassName?: string; +}; + +const CheckboxField = ({ + form, + name, + label, + fieldClassName, + className, + ...props +}: CheckboxFieldProps) => { + return ( + ( +
+ + + {label ? ( + + ) : null} +
+ )} + /> + ); +}; + +export default CheckboxField; + export { Checkbox }; diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx index 09ab358..2de4d37 100644 --- a/frontend/src/components/ui/select.tsx +++ b/frontend/src/components/ui/select.tsx @@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef< 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", + "flex h-10 w-full items-center justify-between rounded-md border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>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} @@ -200,7 +200,9 @@ const SelectField = ({ name={name} label={label} className={className} - render={({ field, id }) => + )} /> ); SelectField.displayName = "SelectField"; diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..10e0c7e --- /dev/null +++ b/frontend/src/components/ui/tabs.tsx @@ -0,0 +1,53 @@ +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; + +import { cn } from "@/lib/utils"; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 077a26f..4922e53 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,5 +1,5 @@ import { ClientResponse, hc } from "hono/client"; -import type { AppRouter } from "../../../backend/src/routers"; +import type { AppRouter } from "@backend/routers"; const api = hc("http://localhost:3000/"); diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 617c16e..e8548db 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,6 +1,20 @@ import { type ClassValue, clsx } from "clsx"; import { toast } from "sonner"; import { twMerge } from "tailwind-merge"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; + +dayjs.extend(relativeTime); +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.tz.setDefault("Asia/Jakarta"); +export { dayjs }; + +export const date = (date?: string | Date | null) => { + return dayjs.utc(date); +}; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); diff --git a/frontend/src/pages/home/components/server-section.tsx b/frontend/src/pages/home/components/server-section.tsx index 3f5193a..3d9e034 100644 --- a/frontend/src/pages/home/components/server-section.tsx +++ b/frontend/src/pages/home/components/server-section.tsx @@ -3,10 +3,10 @@ import Button from "@/components/ui/button"; import api, { parseJson } from "@/lib/api"; import { useQuery } from "react-query"; import ServerList from "../../servers/components/server-list"; -import AddServerDialog from "../../servers/components/add-server-dialog"; +import ServerFormDialog from "../../servers/components/server-form-dialog"; import { initialServerData } from "@/pages/servers/schema"; import PageTitle from "@/components/ui/page-title"; -import { addServerDlg } from "@/pages/servers/stores"; +import { serverFormDlg } from "@/pages/servers/stores"; const ServerSection = () => { const { data, isLoading, error } = useQuery({ @@ -27,7 +27,7 @@ const ServerSection = () => {

No server added.

@@ -36,7 +36,7 @@ const ServerSection = () => { )} - + ); }; diff --git a/frontend/src/pages/servers/components/add-server-dialog.tsx b/frontend/src/pages/servers/components/add-server-dialog.tsx deleted file mode 100644 index 4b65132..0000000 --- a/frontend/src/pages/servers/components/add-server-dialog.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import { Controller, useForm, useWatch } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import Button from "@/components/ui/button"; -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogTitle, -} from "@/components/ui/dialog"; -import { - CreateServerSchema, - createServerSchema, -} from "@backend/schemas/server.schema"; -import Form from "@/components/ui/form"; -import { InputField } from "@/components/ui/input"; -import { SelectField } from "@/components/ui/select"; -import { connectionTypes } from "../schema"; -import { IoInformationCircleOutline } from "react-icons/io5"; -import { useFormContext } from "@/context/form-context"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Label } from "@/components/ui/label"; -import { useMutation } from "react-query"; -import api, { parseJson } from "@/lib/api"; -import { ErrorAlert } from "@/components/ui/alert"; -import { queryClient } from "@/lib/queryClient"; -import { useEffect } from "react"; -import { addServerDlg } from "../stores"; - -const AddServerDialog = () => { - const { isOpen, data } = addServerDlg.useState(); - const form = useForm({ - resolver: zodResolver(createServerSchema), - defaultValues: data, - }); - const type = useWatch({ control: form.control, name: "connection.type" }); - const databases = useWatch({ control: form.control, name: "databases" }); - - useEffect(() => { - form.reset(data); - }, [form, data]); - - const checkConnection = useMutation({ - mutationFn: async () => { - const { connection, ssh } = form.getValues(); - const res = await api.servers.check.$post({ - json: { connection, ssh }, - }); - const { databases } = await parseJson(res); - return { - success: true, - databases: databases.map((i) => i.name), - }; - }, - }); - - const createServer = useMutation({ - mutationFn: async (data: CreateServerSchema) => { - const res = await api.servers.$post({ json: data }); - return parseJson(res); - }, - onSuccess: () => { - addServerDlg.onClose(); - queryClient.invalidateQueries("servers"); - }, - }); - - const onSubmit = form.handleSubmit((values) => { - createServer.mutate(values); - }); - - return ( - - -
- Add Server - - -
- - - - - {type === "postgres" && ( - <> - - - - - - )} -
- - - - - -
- - - - - -
-
-
- ); -}; - -export const SelectDatabase = ({ databases }: { databases?: string[] }) => { - const form = useFormContext(); - - if (databases == null) { - return null; - } - - return ( -
-
-

Database:

- { - if (checked) { - form.setValue("databases", databases); - } else { - form.setValue("databases", []); - } - }} - /> - -
- - {!databases.length &&

No database exist.

} - - ( -
- {databases.map((name) => ( -
- { - onChange( - checked - ? [...value, name] - : value.filter((i) => i !== name) - ); - }} - /> - -
- ))} -
- )} - /> -
- ); -}; - -export default AddServerDialog; diff --git a/frontend/src/pages/servers/components/server-form-backup-tab.tsx b/frontend/src/pages/servers/components/server-form-backup-tab.tsx new file mode 100644 index 0000000..f092053 --- /dev/null +++ b/frontend/src/pages/servers/components/server-form-backup-tab.tsx @@ -0,0 +1,124 @@ +import CheckboxField from "@/components/ui/checkbox"; +import { InputField } from "@/components/ui/input"; +import { SelectField, SelectOption } from "@/components/ui/select"; +import { TabsContent } from "@/components/ui/tabs"; +import { useFormContext } from "@/context/form-context"; +import { CreateServerSchema } from "@backend/schemas/server.schema"; +import { useWatch } from "react-hook-form"; + +const intervalList: SelectOption[] = [ + // { label: "Second", value: "second" }, + { label: "Minute", value: "minute" }, + { label: "Hour", value: "hour" }, + { label: "Day", value: "day" }, + { label: "Week", value: "week" }, + { label: "Month", value: "month" }, + { label: "Year", value: "year" }, +]; + +const dayOfWeekList: SelectOption[] = [ + { label: "Sunday", value: "0" }, + { label: "Monday", value: "1" }, + { label: "Tuesday", value: "2" }, + { label: "Wednesday", value: "3" }, + { label: "Thursday", value: "4" }, + { label: "Friday", value: "5" }, + { label: "Saturday", value: "6" }, +]; + +const monthList: SelectOption[] = [ + { label: "January", value: "0" }, + { label: "February", value: "1" }, + { label: "March", value: "2" }, + { label: "April", value: "3" }, + { label: "May", value: "4" }, + { label: "June", value: "5" }, + { label: "July", value: "6" }, + { label: "August", value: "7" }, + { label: "September", value: "8" }, + { label: "October", value: "9" }, + { label: "November", value: "10" }, + { label: "December", value: "11" }, +]; + +const BackupTab = () => { + const form = useFormContext(); + const scheduled = useWatch({ + control: form.control, + name: "backup.scheduled", + }); + const interval = useWatch({ + control: form.control, + name: "backup.interval", + }); + + return ( + + + + + + {scheduled && ( +
+
+

every

+ +
+ + + {["day", "week", "month", "year"].includes(interval) && ( +
+

at

+ +
+ )} + + {["week", "month"].includes(interval) && ( +
+

on

+ +
+ )} + + {interval === "year" && ( +
+

on

+ +
+ )} +
+ )} +
+ ); +}; + +export default BackupTab; diff --git a/frontend/src/pages/servers/components/server-form-connection-tab.tsx b/frontend/src/pages/servers/components/server-form-connection-tab.tsx new file mode 100644 index 0000000..482577f --- /dev/null +++ b/frontend/src/pages/servers/components/server-form-connection-tab.tsx @@ -0,0 +1,150 @@ +import { Controller, useWatch } from "react-hook-form"; +import Button from "@/components/ui/button"; +import { CreateServerSchema } from "@backend/schemas/server.schema"; +import { InputField } from "@/components/ui/input"; +import { SelectField } from "@/components/ui/select"; +import { connectionTypes } from "../schema"; +import { IoInformationCircleOutline } from "react-icons/io5"; +import { useFormContext } from "@/context/form-context"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { ErrorAlert } from "@/components/ui/alert"; +import { useMutation } from "react-query"; +import api, { parseJson } from "@/lib/api"; +import { TabsContent } from "@/components/ui/tabs"; + +const ConnectionTab = () => { + const form = useFormContext(); + const type = useWatch({ control: form.control, name: "connection.type" }); + + const checkConnection = useMutation({ + mutationFn: async () => { + const { connection, ssh } = form.getValues(); + const res = await api.servers.check.$post({ + json: { connection, ssh }, + }); + const { databases } = await parseJson(res); + return { + success: true, + databases: databases.map((i) => i.name), + }; + }, + onSuccess: () => { + form.setValue("databases", []); + }, + }); + + return ( + +
+ + + + + {type === "postgres" && ( + <> + + + + + + )} +
+ + + + + +
+ ); +}; + +export const SelectDatabase = ({ databases }: { databases?: string[] }) => { + const form = useFormContext(); + + if (databases == null) { + return null; + } + + return ( +
+
+

Database:

+ { + if (checked) { + form.setValue("databases", databases); + } else { + form.setValue("databases", []); + } + }} + /> + +
+ + {!databases.length &&

No database exist.

} + + ( +
+ {databases.map((name) => ( +
+ { + onChange( + checked + ? [...value, name] + : value.filter((i) => i !== name) + ); + }} + /> + +
+ ))} +
+ )} + /> +
+ ); +}; + +export default ConnectionTab; diff --git a/frontend/src/pages/servers/components/server-form-dialog.tsx b/frontend/src/pages/servers/components/server-form-dialog.tsx new file mode 100644 index 0000000..755637e --- /dev/null +++ b/frontend/src/pages/servers/components/server-form-dialog.tsx @@ -0,0 +1,93 @@ +import { useForm, useWatch } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import Button from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogTitle, +} from "@/components/ui/dialog"; +import Form from "@/components/ui/form"; +import { useMutation } from "react-query"; +import api, { parseJson } from "@/lib/api"; +import { queryClient } from "@/lib/queryClient"; +import { useEffect } from "react"; +import { serverFormDlg } from "../stores"; +import ConnectionTab from "./server-form-connection-tab"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import BackupTab from "./server-form-backup-tab"; +import { ServerFormSchema, serverFormSchema } from "../schema"; + +const ServerFormDialog = () => { + const { isOpen, data } = serverFormDlg.useState(); + const form = useForm({ + resolver: zodResolver(serverFormSchema), + defaultValues: data, + }); + const databases = useWatch({ control: form.control, name: "databases" }); + + useEffect(() => { + form.reset(data); + }, [form, data]); + + const saveServer = useMutation({ + mutationFn: async (data: ServerFormSchema) => { + if (data.id) { + const res = await api.servers[":id"].$patch({ + param: { id: data.id }, + json: data, + }); + return parseJson(res); + } else { + const res = await api.servers.$post({ json: data }); + return parseJson(res); + } + }, + onSuccess: () => { + serverFormDlg.onClose(); + queryClient.invalidateQueries("servers"); + }, + }); + + const onSubmit = form.handleSubmit((values) => { + saveServer.mutate(values); + }); + + return ( + + +
+ {`${data?.id ? "Edit" : "Add"} Server`} + + + + + Connection + Backup + + + + + + + + + + + +
+
+
+ ); +}; + +export default ServerFormDialog; diff --git a/frontend/src/pages/servers/page.tsx b/frontend/src/pages/servers/page.tsx index d6c8701..3f262b7 100644 --- a/frontend/src/pages/servers/page.tsx +++ b/frontend/src/pages/servers/page.tsx @@ -3,8 +3,8 @@ import Button from "@/components/ui/button"; import api, { parseJson } from "@/lib/api"; import { useQuery } from "react-query"; import ServerList from "./components/server-list"; -import AddServerDialog from "./components/add-server-dialog"; -import { addServerDlg } from "./stores"; +import ServerFormDialog from "./components/server-form-dialog"; +import { serverFormDlg } from "./stores"; import { initialServerData } from "./schema"; import PageTitle from "@/components/ui/page-title"; @@ -19,7 +19,7 @@ const ServerPage = () => {
Servers -
@@ -33,7 +33,7 @@ const ServerPage = () => {

No server added.

@@ -42,7 +42,7 @@ const ServerPage = () => { )} - + ); }; diff --git a/frontend/src/pages/servers/schema.ts b/frontend/src/pages/servers/schema.ts index f35302a..13c44ff 100644 --- a/frontend/src/pages/servers/schema.ts +++ b/frontend/src/pages/servers/schema.ts @@ -1,5 +1,6 @@ import { SelectOption } from "@/components/ui/select"; -import { CreateServerSchema } from "@backend/schemas/server.schema"; +import { createServerSchema } from "@backend/schemas/server.schema"; +import { z } from "zod"; export const connectionTypes: SelectOption[] = [ { @@ -8,7 +9,15 @@ export const connectionTypes: SelectOption[] = [ }, ]; -export const initialServerData: CreateServerSchema = { +export const serverFormSchema = createServerSchema.merge( + z.object({ + id: z.string().nanoid().optional().nullable(), + }) +); + +export type ServerFormSchema = z.infer; + +export const initialServerData: ServerFormSchema = { name: "", connection: { type: "postgres", @@ -18,4 +27,13 @@ export const initialServerData: CreateServerSchema = { pass: "", }, databases: [], + backup: { + compress: true, + scheduled: false, + every: 1, + interval: "day", + time: "01:00", + day: 0, + month: 0, + }, }; diff --git a/frontend/src/pages/servers/stores.ts b/frontend/src/pages/servers/stores.ts index bca0af1..9e1f537 100644 --- a/frontend/src/pages/servers/stores.ts +++ b/frontend/src/pages/servers/stores.ts @@ -1,4 +1,4 @@ import { createDisclosureStore } from "@/lib/disclosure"; import { initialServerData } from "../servers/schema"; -export const addServerDlg = createDisclosureStore(initialServerData); +export const serverFormDlg = createDisclosureStore(initialServerData); diff --git a/frontend/src/pages/servers/view/components/backups-section.tsx b/frontend/src/pages/servers/view/components/backups-section.tsx index 1c5fad8..c87b38e 100644 --- a/frontend/src/pages/servers/view/components/backups-section.tsx +++ b/frontend/src/pages/servers/view/components/backups-section.tsx @@ -62,7 +62,7 @@ const BackupSection = ({ databases }: BackupSectionProps) => { onChange={(i) => setQuery({ databaseId: i })} value={query.databaseId} placeholder="Select Database" - className="min-w-[120px]" + className="min-w-[120px] w-auto" /> diff --git a/frontend/src/pages/servers/view/page.tsx b/frontend/src/pages/servers/view/page.tsx index 4687957..0d17916 100644 --- a/frontend/src/pages/servers/view/page.tsx +++ b/frontend/src/pages/servers/view/page.tsx @@ -1,7 +1,7 @@ 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 { IoEllipsisVertical, IoServer } from "react-icons/io5"; import { useQuery } from "react-query"; import { useParams } from "react-router-dom"; import ConnectionStatus, { @@ -10,6 +10,17 @@ import ConnectionStatus, { import Card from "@/components/ui/card"; import BackupSection from "./components/backups-section"; import DatabaseSection from "./components/databases-section"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import Button from "@/components/ui/button"; +import { serverFormDlg } from "../stores"; +import ServerFormDialog from "../components/server-form-dialog"; +import { GetServerResult } from "./schema"; +import { toast } from "sonner"; const ViewServerPage = () => { const id = useParams().id!; @@ -36,7 +47,7 @@ const ViewServerPage = () => { Server Information - +

{data?.name}

@@ -52,14 +63,63 @@ const ViewServerPage = () => {

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

+ + {data != null && }
+ + ); }; +const ServerMenuBtn = ({ data }: { data: GetServerResult }) => { + return ( + + +