From 449ba1b9d0a6c4485acafec4fe3476d6defc6cf9 Mon Sep 17 00:00:00 2001 From: Khairul Hidayat Date: Sun, 12 May 2024 19:30:45 +0700 Subject: [PATCH] feat: add recurring backup scheduler --- backend/drizzle.config.ts | 2 +- backend/package.json | 3 +- ...gent_brand.sql => 0000_clumsy_doorman.sql} | 4 +- .../src/db/migrations/meta/0000_snapshot.json | 16 +- backend/src/db/migrations/meta/_journal.json | 4 +- backend/src/db/models.ts | 13 +- backend/src/routers/server.router.ts | 8 + backend/src/schedulers/backup-scheduler.ts | 55 +++++ backend/src/schedulers/index.ts | 3 + backend/src/schemas/backup.schema.ts | 11 +- backend/src/schemas/server.schema.ts | 30 ++- backend/src/services/backup.service.ts | 52 ++++- backend/src/services/server.service.ts | 90 +++++++- backend/src/types/database.types.ts | 2 +- frontend/bun.lockb | Bin 153252 -> 154014 bytes frontend/package.json | 3 +- frontend/src/components/ui/checkbox.tsx | 56 ++++- frontend/src/components/ui/select.tsx | 6 +- frontend/src/components/ui/tabs.tsx | 53 +++++ frontend/src/lib/api.ts | 2 +- frontend/src/lib/utils.ts | 14 ++ .../pages/home/components/server-section.tsx | 8 +- .../servers/components/add-server-dialog.tsx | 214 ------------------ .../components/server-form-backup-tab.tsx | 124 ++++++++++ .../components/server-form-connection-tab.tsx | 150 ++++++++++++ .../servers/components/server-form-dialog.tsx | 93 ++++++++ frontend/src/pages/servers/page.tsx | 10 +- frontend/src/pages/servers/schema.ts | 22 +- frontend/src/pages/servers/stores.ts | 2 +- .../view/components/backups-section.tsx | 2 +- frontend/src/pages/servers/view/page.tsx | 64 +++++- frontend/src/pages/servers/view/schema.ts | 7 + frontend/src/pages/servers/view/table.tsx | 24 +- 33 files changed, 876 insertions(+), 271 deletions(-) rename backend/src/db/migrations/{0000_square_agent_brand.sql => 0000_clumsy_doorman.sql} (93%) create mode 100644 backend/src/schedulers/backup-scheduler.ts create mode 100644 frontend/src/components/ui/tabs.tsx delete mode 100644 frontend/src/pages/servers/components/add-server-dialog.tsx create mode 100644 frontend/src/pages/servers/components/server-form-backup-tab.tsx create mode 100644 frontend/src/pages/servers/components/server-form-connection-tab.tsx create mode 100644 frontend/src/pages/servers/components/server-form-dialog.tsx create mode 100644 frontend/src/pages/servers/view/schema.ts 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 61d63c0fdae75ce2bcac9b13776080bddcb27f3b..a48c755969f1051121e57c4ce271c15587824836 100755 GIT binary patch delta 24575 zcmeHvd3;XC_y3(op2!U$NF+}dN$gt^5%GwSCsAu7LMtNF5`=^-WFeuNhoV$fb;<~0 zucfLjs;TW$ORJWmv4_%Xt7uVcYians&z*aPezl+W^ZES#>wS6OGv~~iGiPSb%$?l( znB{BLg&Wj2f*KugZr7^$!y~60dM~@mZZvtp-sj^Q2i~au!!NOEjoy2u;oK^TCWWrC zvl9IJRv*nYMp1@lWoG0gXABveo1K%CF+^DiEe}PpXC)^M&Vf$f3W`!0d@-mBIyQ5N zRx%!XHNbBNR|zybcW_oxcCz9=tHh(=mZKErq$Fpg4$W7-()kOZE>Ly=tp++QEprfA zTk9nC(^E52MaKxXd> z*NH}hr&gT7jaqjYl)N_UbT>+q0e4s@ecjL>+A+$E(a2S9=PqlT`i>IMXwNd^aHB@Ir_h7liWpeHC9Tmee?M#aNZb5hB0YIdL&JhOeJ z{%r?o|1v1)rzd9(OKz?xn!G&(gKNo98Jd)xGYST>l5?}k$-3xAYTyt<@fcPhi3<7rzC|c0lG)k zK`DS8L8+ILno5u3KoOhv4xkh_+Nu$ln*AMGj8L#U2T8r0?6lNDiZV#a&dEZe{R_1}HF1s8o4GvC9Pfkl6n>+(@a`Z*r&`?msvb{@)Z1$Z%*?^O%kQ(*{ z-(>bH_z;{YxWQ@JdDJOI;AtNH&_d3em7s`BdoJXdI`*2C{Tr;K|mAteNZa6x3zS;UW9C>8z_x@ z59m`rHUp)K;@c=nZO|X#7(H{%$Af`e{(iF?%s6=7n8=QlIDR@?M~rNA?z=)D`ZaexSc} zl=LJhmEWZEMW9sAL{NBKV9!N@LMJ(UP;P445GB96?Bk)J)ThsZx`9UN@}@d((eo?n z@|$r|?+mCmhC06vHbtUTvVses zlz&9$ckoS4egz#9<R&IK>i&lMNaCljLfWLrLl4f0y0SJZBkmca=oAA zho)u>3Cta-jP5TBjNlVgkAe{cB)1Qx$j#rt)5?IS-Dq+*YoN>@nwFH4lRQM(0zGms zIXgEe71oopN987G$~0upgHr6Y0;TBY!({9PfhT8cfs*qRQuHAOrAmTR zC9Mrgp|uSZmD0TtmA+U=bU|BCPh`{wr5>oF z^Y>toCc!mOYWeq|*_z6CRB8~u7(+_#Gy$b%-OZMBI2fh< z!CxFBOC{vV=5zq18L^)SRQ6yWZ3wSyb@cyOl@;(( zA1xpEL|%yd$Gp_Xrh1{pt)N?x2l!aob3D=4#%AzB+>i58+#7RCEgQ?=iM4F% zXXw**&{g<3AFEmseMCMw@qk)Zb-&KRj=xoPMXzJgw>$E4zE-uD&Y6j0Z}L(<8@t6V z{xH~NVRWOX;kG&sJswdd(G)3=!`SGw6;z4#&o~}jt)Yj)qHT|gaesm#k{bNO|6CJ zSaZlsTI0Hxb8vGaIIPB$7u3oDOgrxDAEA067N{pRqvkkpt+}sH1e?Ya>)DtOFT{N! zFRf=&k74RRD>Z9dRX;>5nPdE1Z7Une3+vm|vyctdWav$Ed#tN%_!&zZp4b4R0Imh{ zq-&0tC^ROF2cUFkZfR&Ut*yzuYDJpwBddkxS}-OF^?}w+ESp;z+04a|wc|ye5$au} zXs}=c!S)O|kkNq4UJJ&|q*53xU#mF}oHkhMW~4em zUR4_)Uo2FQY^#ajnjz0b1FTL1*Il!KkujHoizIK&9$3WMQwl>~+SH~l2H#qi^0ca_ zz)|zz1;WnLN7}Eb1==8R)J`qX)c3(rIT@0dbk3xWau6>JvZ({`kf(`)Hgs*}1f~rb z^eckmG&l+hN38=ayfD~?jw=nenO9=1g`>42*m<7V%*L#|u$fIAj;A@9Vtff=d@;9# z*v!`10!Oo&K}^XXQk&e^*lM8yTDO^U^vlFRT|1^ikX2B>jkbE_q9ZrryzyE zt`(tfMv8_Qr4jwNcxkvz?a)Z}BvvU*{|VrzFHAHp>L#6oMK7y*9UOV01-=^G_|Z(l zBp(HiEXzsp5ig9u1b{3GGKJGp62>iUZ0d+6(gs2f9<1htkeQoGJx6T{WPy`o10U5= za9v@e0*y1P#|zuq)Iou=E?1t^z{(bIOC**k3|S2HFlEE7>P~RY!67yyt?DgsGzA>^ zl3=Ua5YH#_3Xy{qF$EmOA$kO3zYttEjia7_0FLUG?R^#@<*D4V(sGKvng zur2h#kqPhrD;;0TUjkc*1;V_LQmeqPzww@=pv#CGm zGC9X;hAK*LtyBP_Bm*4jBM)odLU3KN=0&JJVT$qs=Lcn>K1SVALV(YKWM15No-Cgj{7!`P(8zqV4#qC z4jgr=w6ORI?kYIh#iZF9J=9s+t7D}u?qp-7ys(o^wMH17NntRWTRPj+4Un~aWcM~W z-Ls}vbqv;qL53x@6kPj9n!as~#bpWVoC{8N1aVFn71>?nPj7I~J+kr+I6W}^t)^TX zpVuW)bw(&bj>b{6W`mOzQEU~1)0>01dkBtYH+mPO73THa#*q6s^NG#$>F08dIcP{5c9=q#hxcI&Sr}0$mhjHnp0rCFE5IV zFz-StQA_!BF=S(qdR~+5L#m&aYJgf&wbUC(C26VaNXgu8m`lSn*+!(2wUifvWq_8- zf0R0jlq?YzYvjI#l=R>VQVCQ-ZI4zUNGJ~R7G1z!^_KYweLJCs@Dfb@e zC@s|wDVe+Ek?ayuQZE>zDRZ-t>dcFJwsAnBt5&q)^M;W)q`GOcBBW&QC8TsC7)Dvy z7^K>h5!RER>S=R`Hof3GT zK9N{c3j5g9?uo{FPpk54+|m~-HDoe2C>q-Kkt+*UC&X@Y)MPX;wSFWYd46vHx!NF~;Nb`|G@MC48)%Y~HEIAB_r@mOKT55TiG3Hs|TG1X> z-HVirGg`%5`WfpbEUPi#Xg$X>1RJ3OaM(FoBG@Kw8DKNt10O-1q=xmEw(%s#6KVoD znityN2Qqi#ndvDjY% zM@?3FKxeDD{va)+ydu;bq-X%l^k}2)#t@t8Fj#gsw*06+79444ejd)& zaILf*Z`C34feAV4jt18hT39MD$m!*I;MRg`kGzW1Wc4~Y>Nw4wsZlbYH#Ab6l59M0 zDSkGCBNePIC~yNDIUr-J-B9_g()Kpyh2Y|}{ofU&5aF1rNq(*HH(sBVNcH_;ih{WZ zJLoKvlEUZpiB!WsF2m(|jiw>)KLXbWD&QimtPf8dVN*|!kb{YMN5=%D8S!(hFnOMLc zaXm&p#+g@vi{ri-ZJdyh9Y#gEj>3xgsK{z?<#`XlVf8`FNwY_m>{Q7u1y`PT8=TzE zK+_bJ&F77bRL5k?MHFquerY|pPB4Z)8QNIYoE%wPa-W0i@F=fxu5?MuGv|Vf;%B@g z%sY|7-ZU~o{ROEo=vSgaFt;3yfYd@W1u2RjIh|I6li^G=^*eAh)#TndV2o_2tbgAI)>@Ctn0dwN}@r+2_18*Ji3bmV1qE ztu|2=Rvm7WOD^)yO) zleOF@DK%pXK=c(opD5XzX7no(+C`KyrUPW~b%3s?Q3uH1)N-GsRQ@epPL%ZK0^~p; zKvy~S{=GmtFrUP{EYE}An&iq99+>Z;MZhA6$~iDCutnsE0oRD1SRZ zJ@qL-`MUwSh?0CCO)|>(0-%d1d3Y2c`Eh_QqGa#{;KrllmT@82{mC_q>uC9 zFOG8}Sg1Nix8G3{Pk%**goB<-l#*thCt3|WeZp}B_0efHz*nG3rYo&bbbgZ_4!axx`O4`(|48SFQu1S6zDbu8CB4l$-J;7u3$y~;^o)P$ z8ANGX?bPMFbopPQxEa^yQ*?`f2>&KKZL^ZbUc_`jsow1qg> zlbp6P>uF=8Q`+VHvu}RdewliR#&wdW>y&u)f$WYID;ypEj;L zXKc?W4i$dd1%R8-M+#5owvV2*EiB9cu|rq z)vpuSRi1?V&wSI@Xwx??-1nOpcAckw)3q{I3H)=iV+!2hJHAO^H@W3p0)Bim3isQ5 zFYdqafNvAnuRI_3JG=z^PF z!d))Kuu44f5=QDr7rqUg3s--{NP$cLF@{y;o4}>~gjW3&!>aMrpD{1Uji*Ijt*^%z!%7hX@`oo~4CGH~^Hmm3NE4!E^9 zVps!y7u>3w7_XZ#__gQin`qxHwC`37Yr+$6p?$Z}K5&6ty^Z#POTQh%g83$JDZik7 zzr?T*p85;g_bb{5E|gn-Mf<>w|1}1`VcH8W?+)5`CkDS{%fEy6-9`Jrh4V&t(LQi9 z?#8ePUJ7pNJ+$v$3~S4$-9!8CqkZ6PJp4Y|2d?OT42$NMz|H*)?fWeTKPN2w4ecvK z`@nVJUCPitaBItASSNlL+^Pp?--8&|g|B{q_B}-V9>%a3p7=0fwi8Bf8@SlnDodFC zF>{%n&SF^H>`g2|q%h_pd>Qy2B9%elX@cMY37!`g69jumFx~_~yx2>Eyb2HmR)8Qu zkbYtsk^bTwkpUvy5oDm4MI=dF zA~HxsR|FX>3PD6?_%F&TqP}F&#R-BtBv|VN!7y=`1glgC`l%44iq&YN=Rkv3%Vh*__`}Q#Dr&E)`Gn)V(YS>O|dT&N7iGGCi9*_ zc-u@0R+F$dVos)KN01lo$Ua*jN*b{mX6;AkqNFP8^icq7#YFcSti4wM2Thohd1EeK z^^k=Dtk@@z-R(sAA|KS#{~S-J}ektN2!VA2)&9Q2+&0z8Ay*_<0k?1Az~bT*&qk#mDnI%I9^wz zFA+lkx?X~e^ysrlGC+mxy37@6bhTF5M7=D1j=@KI?Q6!%x{SUD3Z$-$0au_JP#vfNxB>1!O~3=V1@CBh=nW>l{X8xv5Kuz=5}b*}xn?0ENIjU_LM%j?EA=@|cG^4Q~X1 zpSCD%0ernzY+_R$^DgL&Bz*&U251k^_mLJr7|;^HFEf-@KseAEhydyW^?>?71E3)g z2m}Ep;3xFZQeZi-68HdE4}1tr0Ggpr`u=vEUXyP_;s@XYa1po!d=BgZwgWo=dTr^A zKJx+SUH1dvJn#c>0k{ZU0)7O30xknrfUCgIz%}3ua2EI)*bf{8cF}9<6)?UM_ymIO zzz$#wuocLI9KS+P#sc&bz8IiCJ~jaK_Mg5OtO8a53!y`wD(ExDGJw8o7LgoS0;Ga} z7g!6t53C0kW5rRH0rP;_0KI*i3A_SK1ttP71CxL*z_S3&x2`}efJF(vpAfspvij;U zRFXm!36{qiRL@2-2j~iP1L92L^|7pV7y9f=^PE1d(dW6Xz`uY`fM}o{K>ye5Ss(3DHS|?y0n$?dJ1_?54-5eM0Q7p8zK`MQb>Ibn)qdgbA#MZwZnI0h%TU017yI z0-|@7FU0DXnV2X^X33q@diW{g4;EOc< zkyQ<-0aOQE0n)7kkeqm;oIrHwwYC#Lt)%>ppj`lC<)dDs zp07k*{v6N)=nljI9Z{e=Km$qgUO+tXJkS%Md@4h)k7M z7z&VMR2j*rVxoq81kyAlHG$zsrvfx_Gl6t~mMm%vEn8`z8Gunf25G8`c3fFBl!j4b zylFTMqqJI(6BKJXKsNBWqohaq8_0kzho!j(n-m+iu zzG>_dtH}B{4{8?FQf!{iGD7g94ZHE*5lyUr2y$AK|L^fm71J9JYVB8|=UB1k2-|CH zK%Evrp+RA&;0xHt{?PuZa_RXSGln&RVzZ!7YpbAUC&iA}Sg7}RQ1F7n^WZdJ``F8!41STa;l|4@JG3LZK2AHjI6xwtvw7H=#IROeeBjy!|FxVw`_6+4Y6Av`K6MwInEn`sr736GrM# zCsN=T2caZ>a@D-x?3IR4r0_*;J|ddK;40A<#QV{~DESXELl=8TJ5Y->gVACpDGWiV zxWeF_d*f1e_I~Sa6N`n!1CkuEZZ@-Y=U^#Xh9vIVxv=VDvx&U~i9005*(mWxF4rBK z>6!>1TFDsw7c^X;5#oIPr^X%K98ByTNN7@-D~qOYVN9dJ(+nv~V;_vH-ToBHwvc6W zM8FrUW-E-Yab`;&w%~NfUl$?Z`iIFj8mF#Y9?~cNOq=cPwVJhvI3w1+h1P3EENr(p zN1VI358`c{&C)G+=TghQU2Z6)n;jHaR#wb=V9^|qAt#)|Qz@RaJqgPK#ANDFv} zv7hW^i1V{C6K)I390Zqf=8N;C1Yg_Y*Y2TuOgakVBVxiFs9zNGLA;H#U-GYSJbBu- zRmK*Au!S;09D+iaaVX5)YRMxWv^Y=*LzwiA5yTg5M$z3%owiW|W1!BLe0!i%BtyHgoZsVT4#V7y1vylEcic zqULiAFL7fov-=q5w!{y-{P5lzNxx`ShEgC_@e!{VVyKN1TpW5g?|H!SozI}559124 zqY(Aa7N-kg{drM!9-42Q&+L#W6lIGjT!7Y!GvKfLE zK`1$IT7Hp+T*bo+5oA?CByaMea4RWFZUnG0uJYqCvaeM@FW7 zYhuk1*@#i4Vgqq)A@el3HW1E>SXZO4_RzW}MlFJ4H^tkF(5=fwTnVcw)Wyt~JrE5R zBj2lZMLxtwTArp4PhK_>9Mi7o*VJN;PW+&&1&UEF@7mR(>R0XhphXne+ zi#W85HSu#rlAfjix2TpQGB+@HygUqE4lCO5S=f)_%w`xX6X)Iov50D0;Hq(c%+lpU zM(*B{SDo4-XGITjK!*v40w-)&UuG5{Y&mVtn zoE+(s;`gf@KCFS*A3iqB7_gFe9yOL}O|Y<2!fy?PH^jJ|SgoAHMb9;?|6gj9)^qwm zYQ}!wt-7`5;*FK8`eR-GU-iabv3f0{u1uU+i^w%jLRuZ?Te9HI!&|iZtaVB~QH>&8 zCx7PV@8-mly%aBk-~SsC`UjEpcvtB&P)kq3lx4E z@DMl7hjSYuPA3L@;B|d| z$o$3j4XnDMYzR#~qDB7?;dh^Ck@sP_vATva<7CGto}k){lU}y}y+u5E{v;Bf2s2uM z|9U__&FY^#<^J1fcr-U3d+Pmul>hp=`8QX8Bif&wKQd~)wbRq^xZN6fGrDEbuBmcU z+Y+&;_wIi(d;j8c@jvTTTJcOTwiPMcuv?c??mzF{r|ok7zNde(-TB|TR=%Us9?){Q z{$z>K9@Ycu{`)1Rx=B3P$?Pq?@HP*BL)&Ks_nA?DW7}{yRuK}~I;JJ#98X;H+8n;P z&!xgOH`bU~T=hDN4|g#;zGR1e%0j~?qJTFFY;mhuz2D5ji4NG#hG>5b7)R)Be0ceW zv)8_Q*@EIa)qX5j z?A1y5f5tq+jAQ*i1U9kiTrs|!=R@C8R96ea|+meu`zwQ66TrsAzxJF*==%)QgEBo2ApA@ZWzAfB= z{_fE>=Efm>dn$Gp9WSQuEYGQk{ic`+ zg_gzvh!^JM+*o4!WlOn&aYW)&hj&v3RBN4Ap3}6uIQu#LkLoTvcE|Zyt7>?3`n6oc zICgQ*FLu8dm&fz+oS~x09=Mk;I_$x|@PY^^Wo`l5U!lhNdgrE|&RN%OpA(*xE%4qH z59wOH#d~|0XG`PczMAGl-)_Dyl*C8vg@v?uzPI=d zW&h~aLo0l@62W`%_uXJIp2$?Oe=qefKKRjJp7RUq{wtQRyaj`pJ@m)iDsk=@{`m0R z$GUkN=Nek}%u>%x{$LHX@Op&y0Qicu5AC}xHWE1{Mx10pj#@Ln6TbT)x+;1Q`Atj) z@ixwF%(xqVCwBOA-g?_no%RQ}#T1w*7N0~ji~}P5M?d>PmfN%2wT9CBU%Z~eyETic zW1?vD1-3|g#C5PD;|u2HZJZXly~>5voi+@)jzaX;FaGR1EtY+O>W$+j^ETbKnp=0F zcF-RycqU0R?6jzK0HdZGjXc1-!i*ClcT|tKJ)rBG`!!FrX*B{L2x$50)nk9RB-amV z%R}$jFifMxj05Q75#lsi`%T)HmwP(t?S{cDQ3gwlizI5o(=1f-_NT^pQIFq#sQV9 z!)K3t&T?as1KwzaX)j-@WQr~c{Bn)yV&+kFt8uVqgG>7lyj&yp)p7;n=*{$QEh|sVj-6Vb zb5xu`QSYnxTu$r6{$6tq96ma6fEz75c;lofTDvWH9h+H-X=%w3za7IqJtRjABpRP1 z#vWr`4Y>^Way6OVTy#E;_qNN$_~YovRIv`k)MT{y;W+yLvap=M>{%=(oG7>NV;tpa z`fAFHbEk!7n3y#PpQf-uF!vT;p1`D#4teh%BVV!2D&Dm#wRQ-0p@!l`;d>Gmbrarq=1(^hYbsY9M`6@7sv_UWA1`CB09fl8?lUQ!Lhb&l&k$Z|Yg`{V5hzEhi~8Z43^l?bP>h{i{vZHF#Bo zpT-aKUKNQT0aNfvil)Qq_;Je<*7#dyfu~`b;1OTtp<9cyMWXODvo(0wONuA83j2cP zReA97Sy_o=B0i(xa&D8`D$+khd^wF-iaoz#^H_zXLD|Ji&M?;yal_x_UtChfG|t&O z2h!lcV0kb$F@ZGSTLk3{8!MXCGFilX7L%)p@iz?;_F5({ajS>Pn=;-Ni#<(_qN~MZ dEB+Vu(+r0-?T66{{fykM>GHc delta 24369 zcmeHvcU)CRxAvYRN7*Q1!2kjx5(_Fw5sr#*)YwtbSPpg(QIAp-Y}mjg#uAO1iB62Y zC1T5~CNCP3*b=)@Y_TgEqgbNYyZ2dp_7=@+a?QQ>`~J%Q@$5Bg&6=4tYu3zehncrd zU9w7@7U-Y;!ACt;Zgv`Qs#X8l_m9-x)!*U!^YeUrPYGz^lHoLQZ>noYlR`(vbem6? zazmKLC`zBS)Re)ADZNJwNgo`a(p#AXEjLBUN=uAS7z~|eMHHn3_{E?q=!n$bTFHjc zD+_)zxZwdin+M)Yvqgt^?x&-qA@>N`j8p=7-}bdy`n#@$0%PHR*^wQJ;PvFDb$$N8D>$%vAlq} zW#)k+`@cq~shNTCgZl*xNX)ndKTtmpfRb4^$|#Bl=wVP#P#Zi<@=f5&fu<#71}QIv~Nrgpl>dNZ>=dP^!Kq^BpQCn!p5WKe;Dsp*3u zQ)Z$SD&P(|RhSZ=l4{35*8@+ZoDrX#L<4orK^h#S>)Bu}m8+!J_Z&QxA4sE)<0^Pp zB~!_Ah64HHIVzxr?}C!z`{*WlpjTRaLSnk2JOWSh23}I{6Hv-G8t!i&Y^RFt=>b~s zjP;iKd8m)-Kc*X)oS4=(F+@=`d1GY6R*<36Cq8}fAXJc+I3%5nECoMO4|72&LcA-= zp|a}wy_1Ggd&j94{#1miYr3qwovGT%b;M@sspRP^vRMH6FXce@IGFYJ6`+IR!cN_;`oX{#UBX zx>8dT63NbCY4HOG!uNliI#u}=2e-_#(54oipavSZ+n{8bs4nG~z>^pHrw63>iw{+t zb&H;%2Nb}!KqHDEq9{jxksW&)1$=*v*dMW9HGm`8W z=UIWWA_U*yjDd;Dhv3O&$7;z66XKH-lk6iBGax5J`+<^+-UUT0XN3jJZvTn~r~^Nu zLF(9gzQOF1xd5CyxP+wi;pCKw;AtKmtS#pa2SsFN(JF|kla(K;C&S>J{$ zN=49-;HjPuEsEj`nh2g+kJahDI&y%{fRf&JNi(y|P@o3aKtL0BHYgSRrk*srbcF1t z0!rPAgg*Jv3zQmaSYJ^pfgXinGE4+1(SMB_SURN)eR=T8`%1HLLW? z1)X+41{su(3dm3UK&i)TK{4gBX6pQCQ1VOyC=FmcP^!2&450F%pwywaK)peCy(2y7 zhdD;|DP$<`R?IK6pqbP-0!lgSb$&i5wJ;X60%$i-%!{l@Q1ZEtE`QQgQ816PPJogt zHiBY~X3f#*7*HzTL+9TCrFN=;qDProo=8yWB&PQoVo&O=__UEeE&)nDeGn^6{|%IU ze^BQ)fl~e|T|P^fzYj`dkgD@>pyZN9y1W)BmGji{-%+wuB&g!MF|whvpj5$LP*ThR zC0{Pq^QY+f89LvWZ*lZ#do4~*t@AqlsFOa_i9=H;w#R~}sh6VD;h;1ldM73h#7tBU zp_~i&9iTLI7x7@#E%Q@wCBaPurFgLSO-W6|0+I!uO4H&RpOmhMZj$d~Pw5>nWT4{J zUFw(C>A--b)P(rKXe!B`Y#*$Y?jiFBq8^HyMc~OgTckZ+CL|B!zD`wH6W+q9YNk8N zxkB~~DRzM|8Rql;SACF56rI zeGl+cKq&wx!!GdrhLf6^Hb6SNFDSXS3nun z3rQ< zrN|!yO6Fl+YQqVlwc2%E!xEOF{IfFsN{#5yr`Yr!r6*sSb&Rb2!BHiE9g3%o4q zZE$3zBM+`%QF9O!#G#&w7WE0Z`rwN4LH+7DVmOE~^8#-R8^iN`tn3{3uV_`n;nFBs z1quz;IR{?gX)$jC*N9*Dj$p;Pzps@w<91)GI<};&s~8{TYf-<~xuR6F>0wD;y;79g z%tcnJ@6zmioghDp&JvLe)HH4z*c;XqZg#XQ^3sygA)6#|(_ z>s$Ro4rZo+!vais6)om1;2QCOiVgmf2(;hWR3VL_XzVvq|iXU z`aIv?swQBX#GpciUj++W!|m0rY93@XywYW!n2|K1a9QIz4q&u_Qolf|3B)ei;Hc%L z66}K4nt%&Jo{0um9RjY6RslxE{1dn+GSgfH%UEMdVYu^aTGh$m>&a5?7Iix~>Nku) z#5rK@krBmc@YNV_G|pO(sk6aRIT?*dbk3xWaCx2`XjP*zdufh1@HKuG^&@ap7d(oH z*a40rqNwHoGtaJNh0F45SV|s|Sqnyl*?E3#EBluFhgq4* z?O|4P)Z2;@!LPeVu=jXAq~CM@aI4v`GV0&~;SsDi&komC5u6q7Z?Uo%Zns#~PpZ6# zZj7{A)reJUGZGwmR$4w%&y(Z%6*!6zjHi#q{0Llgp4_j#Lp6wbfJcNm4Jkx(g$VTv zq-c0i5Fvk#=hwBW!T!>TSey_J$>7KhCK?iTsm@`FmA9y;!I2R%Fg>fkm_7*8Zs4e9 zEsrhY*%63+$QnQU*D?QYREbeZm?hm&xWi3GSb7MhqbADO=DPKqxuuLR>&(t zL(EF^>`1E`6Cm3v#RpZjunF8h3QH44D_X0gpsqz-4NhN%qAcn;a1{Os*;*FW6_+S# z4MBs}+JlozelUi861W(xHFEkDaMZT!Z#aBRo`El5NeZ_&u&UXRQDC5lko9n z3S2}N7|nL_aWgCXiDx&ns(ulMHwVG{y|{mKt2!UDMlb5k1E*V7)1oF|UFc=hr2Yi1 z@e55g@9R zR=`?_EIAF(9kU%=N1j|E!n_HoD4y&Up*|(q3sc%5dfIE;8g&0laD{nK!O5W`*R)1Y zsZ}^Ju#O`bX)HB!8k`(^|97OJ@&Y#q9K{O^rP&LPmQ%@D(L31`H9P~H97im!>RE7d zyeKaKS0-`=)(phuD4rc_HT~9vUx|$}mxNQg@awS=W;;@ywA9uYGVd0O(oT~NM5>#X z+J}@~ONFDocr7K6lDT(~>Z{4RU>+rEsm)0B&{95#6PX%=lq_-bg{)p{!vc9rsw%X(wfr5W)^Nm({hvc9`W$r2GAXikv@ zRZl#Dc5xz>TB7*{xIRtvz@28ZhgZhR_t zgALRUZZGUTtmXyaBgjkYNu;Q5T)1&ns@h%Fp*;|&i8_bd6XY!hhXqU5JPD4<;W~rX zym}aI6W19Wc}lwnR9AwluW8~MVY=LdUx|-W{d&p*cyhp$i3cYuqs3l;qlt$JgiFk6 zJr5UHbh2!`)WjvjVo}?HqpHzKEbvpoQI}O7+}vW`3l7gQ2k(~aeHs8 zx*W0w(8cXN+P|%9Xb%KxK!S14NcY5}!PU`jZNEV(1ZA`fj(Q1PO>kH`Fvw+lzsLjE z4jc_kF?tqI1vqk?=2X-E-uz0RD77XYwaHjCiTLRWj#RMLpulI~Xd%PZ4tbZsVK9*g z-4z?Ly$y4;5^?-!+Z=&LB0c~FM8OkecnS2{e-y?%1F zf_t$5PXI@G7zns9Pv_+L``TZ=UL=8|IMzno^o5;Y=^v%ufDkj7`i8*o+Fwx+(Zoer zaIcm<01r|FxrF9C<87}X&`{?uAgn`2I5Mbf$1;IP@D6Ytj5buSVJ~xEfO}yLbK?1ft!k6ua_DfQoP>1@ z9QjgOe*#={aG2w`f2@=tTP>qav1IN))M{Fr!TS%bSGu~QC$@V|Btr2<&Ew1a3-025Im129Q6eZ;c*fqontNmisED z2F3wIKh*PyQhgJ2{$)z?Nr2Yw6kXwUlpgR5paJ*+p!`h$`EeUS`9A@45GDC8 zfaH4sI`&Y=kcEc;$~XehL6j;y3ea3R1<*m1)dh2I_FlS%AFzzkdgh`$QZL6rLWJ25zj5?=rm2c7_Q{1qkr=bF}QsgssP3XIPk z>6S@Wn_vT(QG`x^LMh8Z&nH?Md^u3^nx{^EL8+saLCK72I$u-gYk|@d8Lsnn8ODVw zz#9_!RH4Lx;!laii7INRQyVB%*cp`cd+K}wD0Ltaln$bl?1K}P?+;2H9<1}jLGh<# z;8Y%zGsFd1^trAuk8*Jkr3M#*(m;IyN?BjhNvG7n3SGVul=Qy>r8vvc`EPalJ*W!# zc2N8&J8*IZJxSp}J<0QuTI?z`)=)a+vZ>sqJNDt%xR~C}1`=KXQKtI-dM3jc?Q=KPD$(cBjzR=}w zMA5!hfC_%57km>+`m=O>qLiGi^F%3sj!x(5be^71l=A0;W>VBH(gllkg}gk3 z*MFJPblhSh|7!`^@HSnMC>i#n&c94av|Z2N0ZQw~&${00C>eeb`DDluUGD|e)?Yn? z@_xaI=J~I>;{PS34qepiB}ze@c@+tgUDq>+(h7DLlnUI_EJWlEyw zI8jGRpxacRD|P=56f0}E;(-jRs5~eY^wJfHlAMm}I>?oQz(056%0GAGnXkJGM{p=4 zr$~AEE}Z1VQ-sl7I2}YuPXH8Ye{~m*Y%QP4|8qAk7qfrv#wjZPxf}0^$wQ0wKX>E* z+>QU$%{0xGzs~>XZX7Q9=WcupuCjEY{pW7{|MT5=`9HrKUsw0zw<|N#y6)?8TpKL% znOe;J-Od&Xr@yJ3(6?Q8`>Onv4}NsS2@5ppvjPYnI6jn9hrmcDam$j7|`3*s&n zsXr?BYN&Uc;ag{aIO^v8bscuDS9$WGXdZXang4Vsn(g4F58JroA!k11a5R30wgudJ zaFvflvpsy!5gYG!*qI*zw~zZCwQ=_&&V1a_Xttl{g4+o$?3ZZ#>S*jQHa`5QGyncz zG&{uALpEOR7iWGEc}IBgF&jSyZpN`__6yGgH{qBwZ*n}E9p{}6+IXGg&U`MolRW>p zjb8@W`b0E4&1apk@y|{;^QYj>@)jp;y!lCIzUpK&%i|AE+SqyC_LPlX;Mq9;%Gqff zyU07?{2O0`^ChmHLC?-O^W-zpET3;UV`Eo%>9aQaH6qT}_!gY6bB}X2_B$Vh^9{c1 z9Qt(5nFr)GD1nK9>#JiH=0gF`$g{CqJQwHN-2c3d-Qi<#zRQo}e2)iTu(A7mBF+zZ z9?lPW-Cu3&5uc9pW1jyjI`u0$bupSf<+Co@*fU;m(PnzC@Rq-I#CNZSzu6e$4{z(aeoc{2e3pyEDHI&V$#zfswj_k-8DhJb6C2%ivnyjAq_^)=iAmO^g&cAKsz> zBUONrDu`yj{2{mp;JV$4X82v&EgN5U%b7dhj%Jm4r`t9jcNhf(JYeB zdW`-(M*qNBd5b6L-xKujNi=K7AA)-TuG`aS_72Z}ivB%C|DHwT_pF_sp?}ZNKXA>s z`W*dxj{ZH5W-a&zaOP9b5Ofg7NpOq=kq!{p#6$6%mu$~0o#UU6V?8PDIR~&+UBuEw>B_MDw z0m0}J5TuG-B-lxU0A~mWi4125hC4%Wngr>>za#|JNwo zW*=zH<}@$QY8KN9PWEMvUW1xQ4Z1X^r6wiezXFtBAj6w`&Di#gbXDWuE-L4g_G86N zj?p&x+i)`a6lu(?0xNEJkycQ1cVt21xBe+PrK_=bOpZ7E%ihA!`c%%t_%VG#l0C40 zy1YlwepoLimV)~RmS>~1n#8D@Xrapx*)FxvmE~LxU=N!)#p(b5czCGr8po)(`OC*X`eW{fzm8k}f1hJ?L2B@*IdRh7~ zguu|gUc9Hv=nF+3fWGm(ugjESNRWFqWlYv@jmc9hu1ik|<(o4|akhla~2DSq` zfK9+=pgQ0T@00{qqJf*DU^pwAc@oJ}z-izNa27ZRok28p)V#g0Q#yr6?7WF zfqvju04ssjz#3paun3q1dJ!UE&5f+Z6xjh_kjn%bzrX;I|>1`8%cVnejj)Y&=&%F?%4`tBkxOqK6fO6(&vzo zpd*0!NG|~BBM*IzJ`2zb$*%zcd;p9BMgwDj_CN=~2E+leKwDaP7QxuX0Db!G2|Pyz zeOG=8oToGzpa-z!NGAjISULxojr2Gm6Br6~1G)p9fOi3U3)CB+PwKw_$AA;SNnkG3 z0osE~Cm@jrqybpW>6gjc7id}By`$)yT$4Z%Hqlxb^AG2(XeZbsA#Sd9El}70+V6E`|ka;%zQ^AldsF_T+1XzmaV z#zUP}KN^KIqV;&@XTE{t0Fg1CSxsjZ@$GolIkNyF8nbAiCD0gn2cYRm(=;CW^h<+! zfCUHy9|l?*pnq=-CK(U_)Bvgj{=nNn1%M*m3zUBDP#*9AC?MTI-=d!~ltZE<;0npz>ai3fTnB;kOa^|MV;vZIsh~oFv_LW+EHHR>!Ga)7F)SF3IE$~K=t*}nxbMqaH)4FHifRA) z8496+p>+d86+dx-nxb2zl2BNkC`vw^a?lqFA+R!t?xBLGves;p$ehYDRJsCUhGaDp zY#Q?@YwS02dD$eFqeqJlG%+p$kfA1Ei7N}eA4Kdll>S-FoyJ1hF_DW*mM5-47;fwq zG2F|3Aa6qVTUwiN6~1Sqv=d5SP5yCq+@-dAkW(8j3`3kbh?ME*v9VLdq_qCucWary zUF%Q~m30>jr=vra%(6p4srzS5cN-lBh2XG2sy#^Dn9e-Hj7>9QA8z~RJBMDM>um%V zZoAQGh2ItDaYMgDPB>aeP8SjR33_uteDn#raY-!wgoT=Jn#GAvSSwFfRQ~hsm9_~Z z+k8}Q42q#pU|qTjH001fB)SkeCx&oVL-j8vM_*=o$~{SxERQl}lG^*Z97gF-&0J-? zI8DW-3E$)FE#dqr^JcR|jZY!EAewy2YD8YcwT{BX*ng$drr*tT&RnmCDng{)#*Qqu zwuf7HyR|D1ITVuc)~8|>YVtI;Xjzu=EH!k2XF~_gcJfw$$RmX|h@Mhti)*q5`kQlqPbU1YF?GdS^I-&78vS-Vo z;S7ynr-G~1o4Puf*i1-VAt@*>#>~Jl8#}^0O=ABVSgG*|l&vkx_7rPozy)>09uQAs zr$Ogq->OfWZ5)-Y_6HjB8n3!5y z7kTX7<_d*y*=B$k3kB3gJ@zzqt?A6>oNRi3K4RYple%I*DZIw0x`81yePs=}`upx> zZ#iHXItz=y{Kyjn1Zy2;Y(V3bZ}YY;nEVJeV=B^c8C%TcJ=?dsT5adgkQ0n5(XGSc zxiKW9g^{^i#Mu5MKt9X zid52EAtrtXn=Xp)iF_mqNT|&K5ApUaR-0)v&BM#sm8Qm##c8<%)^^oxg0forq35;dkEX@n(`>>t zpdDw#xzCw&0uoL|wrM0Ze6YkvRu? zd14>*@CNPD98`iu$!jia75-ZDwb3b8N$w!hd-A_K+E2~7Au#VC|Y?_ZcWj#eL6w9Z??fEP; z+}M+5Z`DTI4-HH@Z(>0ROpL8!>|V3y;gySDPpvxL#PqdCc~V7mUBFry)oE9@Q)1f! z7=KpWUjRQ(6HOL^yCO1(tP`siVs!p|HLEJ}7NV38wHC1^>ZuxX0Xif`FG4Y6_nUy# z?>YNMZ1tf*4GW|`M2Iy|z$?@}RBTzzmo9OxMG>NSD$uGBtro+Hzly$#Sq-P_0Wwza z1&BF|u~Zp*-fYX8JTrM}Xi@5>Hreq`WB+2bZ)}otNPYBRh%MpXVW##Zzcwp76nT|yhQN7K_W#akyv5< z0whrcZvz=AqKR@!qhrgjO$}D3& z-@JC&BeDA{R#Po(xb$j_cOk2};9J3Rs4Ln3t)}j!55=0VSg1IV!)m^fJ+Eu@Z+4!| z78_R}1dNS~N;oA(dG+(TFQ-;0Lh98Kl*7Yh*O+Fo2iq;Rs)|ip?7e zTX}Jz(k_Fp#k44pmIa9U8?cOZ{+5+_eJfb_UKf=55WEr*qWebH^o{1nA8PyqtA)g? zWBk&9iJHsau$7lW>kona%DVk#A@ir}_#cDgjU1qv_IKB`*Is+%MaR?F!0fw#+YQ6# zZo|}}d-iZl9es!p-|PO)Z2do;aj&?~Amh0vw@|DQ+TAim< z`6jZiD_hk>gl}gRO?#S%xa};9d5YuPalxr3oOj@YW9(e_>!woXYR6?10<1VC@dL zhiC`|Hb!&-@iKP4TUXhkThQfkWefE-HWkx$;2Fl)EAOMJPv_+LmA+D_VeF>&p!=*b zwdd@9UYO$}PLmPF_IsA9tLlV2KG3vKp_?eZ6Gn^`H9)+K?fb07+V)ACu&Z~W-uh;u z=S~=5Z20GLJ~z1LlRZxgHH<9*C#21;dU`?&=fa%6VihX)G-rg7yv?(Gxq<>U0BNd$7=bpg?g#t zITXT;4Fi*UE_!}+`UbrEFnVKbDcI|+C#s8&>%GF9Rk0#wH**g+HX)qgu(Dr|()ETH zY8cxT@`!7G{il3Bp)lvSSTP4>J&lbGcizkL8MUMXFVrX@_U}dqd_?|kgx?h5y$5C* z``P79I5~Jt%x*_q4Qu0FAnqQFo$(UKHS8GGtQ#th8x z9=EkVH1K8$Pqeik+Qb5AcpCezZmK!^L_`PomfE#81P^?Qn>e%w&nDWdiq0&wBfZ*8IymP{&N52#67j_p-kDok#I~=)yozc^|r8d zXgE=9q3ln^T_THw|Ic`s-VrCd{|w(6Tci4z&UbJ7`|tF6ghS*W$!ElB9@-sGJQK0sCRsSkY`hxR1n0aGu7VjO%t~e$uG# z%F24@QF^TSiquoYE>aibDXANqDt2tUCnmns*LaT8-uvJw5v%0^_^PzXJb+S*#H<5w zg}Y$6;97{>T;?ijVhdoG?N+xL?1@2>O`^GK|z>Lps6@G7k0K}eg3 zx(8uIyhuI>BaH16Kd>J5YPg$G6!KqU2%idrwPA9Y&Mp%O^5JO^v``- zRX88UOHpGN#TCABiyn=8{^+KUdI4qKVfMUL zXObTE?69;@<8jK&U$9&n`$#rCcy`;IE?;C6Di}LWdY7M{zhYy>3WYf>MeAP>FUHoC z$uZ$2-b-(d^~kVmgcu72Ov2fu_a`01BjtRt9}1>T>0-e#7;9`jneBag=aIEd@T$%d zh%XnoBYT`KDjZ|2UT7M-gkD;WP0h~_!b7U|q6t;%b(v&|5OM7o-YRPExkH(Y=x`jX zLGBPS={N>R`*Pqaa*wliUdBF`t50pb=9^x#ycQo?kiJ@>yXR{c&0tcMz40`*zMP)3 zZJWJPFzzdjUVJRJoIrPV)8w)hYC04r8lQwYE@Ie8*4oS1Tr~Z=4l7ohTnaQxw3X=U zFmdiA7FJ`g&3Qw=3$PzFQ!HB$5Lj4W^{45VbkY12z9Rg&U!_H2=P9hBF5tu(KOKdADdW;57pQ6UPbtw7Vwf32bqV5@HEx)v*6n{`BY%d#L@|pW7*-(j& zIiH+in^{hubL@GLxYNi~F=t$9(?};V#oJ^R5#A;zQQON@O4RT&dF1T%HjQ!+)5@EQ simP6xIXTTMnR?(|yC3pLdYMXyHeRNVBJLR;ac`D2x#S%5GwlugFQ(KNuK)l5 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 ( + + +