feat: add recurring backup scheduler

This commit is contained in:
Khairul Hidayat 2024-05-12 19:30:45 +07:00
parent 3d7508816f
commit 449ba1b9d0
33 changed files with 876 additions and 271 deletions

View File

@ -1,4 +1,4 @@
import { STORAGE_DIR } from "../consts"; import { STORAGE_DIR } from "./src/consts";
import { defineConfig } from "drizzle-kit"; import { defineConfig } from "drizzle-kit";
export default defineConfig({ export default defineConfig({

View File

@ -21,8 +21,9 @@
}, },
"dependencies": { "dependencies": {
"@hono/zod-validator": "^0.2.1", "@hono/zod-validator": "^0.2.1",
"dayjs": "^1.11.11",
"drizzle-orm": "^0.30.10", "drizzle-orm": "^0.30.10",
"hono": "^4.3.4", "hono": "4.3.5",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"node-schedule": "^2.1.1", "node-schedule": "^2.1.1",
"zod": "^3.23.8" "zod": "^3.23.8"

View File

@ -30,7 +30,9 @@ CREATE TABLE `servers` (
`connection` text, `connection` text,
`ssh` text, `ssh` text,
`is_active` integer DEFAULT true NOT NULL, `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 --> statement-breakpoint
CREATE TABLE `users` ( CREATE TABLE `users` (

View File

@ -1,7 +1,7 @@
{ {
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"id": "96dd8a39-5c64-4bb1-86de-7a81b83ed1db", "id": "242cd56d-c814-44c6-8a5b-4f0814248f31",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"tables": { "tables": {
"backups": { "backups": {
@ -233,6 +233,20 @@
"notNull": true, "notNull": true,
"autoincrement": false, "autoincrement": false,
"default": "CURRENT_TIMESTAMP" "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": {}, "indexes": {},

View File

@ -5,8 +5,8 @@
{ {
"idx": 0, "idx": 0,
"version": "6", "version": "6",
"when": 1715367813285, "when": 1715513358120,
"tag": "0000_square_agent_brand", "tag": "0000_clumsy_doorman",
"breakpoints": true "breakpoints": true
} }
] ]

View File

@ -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 { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import type { ServerBackupSchema } from "../schemas/server.schema";
export const userModel = sqliteTable("users", { export const userModel = sqliteTable("users", {
id: text("id") id: text("id")
@ -27,6 +33,8 @@ export const serverModel = sqliteTable("servers", {
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
backup: text("backup", { mode: "json" }).$type<ServerBackupSchema>(),
nextBackup: text("next_backup"),
}); });
export type ServerModel = InferSelectModel<typeof serverModel>; export type ServerModel = InferSelectModel<typeof serverModel>;
@ -96,6 +104,9 @@ export const backupModel = sqliteTable("backups", {
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
}); });
export type BackupModel = InferSelectModel<typeof backupModel>;
export type InsertBackupModel = InferInsertModel<typeof backupModel>;
export const backupRelations = relations(backupModel, ({ one }) => ({ export const backupRelations = relations(backupModel, ({ one }) => ({
server: one(serverModel, { server: one(serverModel, {
fields: [backupModel.serverId], fields: [backupModel.serverId],

View File

@ -5,6 +5,7 @@ import ServerService from "../services/server.service";
import { import {
checkServerSchema, checkServerSchema,
createServerSchema, createServerSchema,
updateServerSchema,
} from "../schemas/server.schema"; } from "../schemas/server.schema";
import DatabaseUtil from "../lib/database-util"; import DatabaseUtil from "../lib/database-util";
@ -54,6 +55,13 @@ const router = new Hono()
const { id } = c.req.param(); const { id } = c.req.param();
const server = await serverService.getById(id); const server = await serverService.getById(id);
return c.json(server); 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; export default router;

View File

@ -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);
};

View File

@ -1,6 +1,9 @@
import scheduler from "node-schedule"; import scheduler from "node-schedule";
import { processBackup } from "./process-backup"; import { processBackup } from "./process-backup";
import { backupScheduler } from "./backup-scheduler";
export const initScheduler = () => { export const initScheduler = () => {
scheduler.scheduleJob("*/10 * * * * *", processBackup); scheduler.scheduleJob("*/10 * * * * *", processBackup);
// scheduler.scheduleJob("* * * * * *", backupScheduler);
backupScheduler();
}; };

View File

@ -12,8 +12,13 @@ export const getAllBackupQuery = z
export type GetAllBackupQuery = z.infer<typeof getAllBackupQuery>; export type GetAllBackupQuery = z.infer<typeof getAllBackupQuery>;
export const createBackupSchema = z.object({ export const createBackupSchema = z
databaseId: z.string().nanoid(), .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<typeof createBackupSchema>; export type CreateBackupSchema = z.infer<typeof createBackupSchema>;

View File

@ -16,21 +16,49 @@ const postgresSchema = z.object({
host: z.string(), host: z.string(),
port: z.coerce.number().int().optional(), port: z.coerce.number().int().optional(),
user: z.string(), user: z.string(),
pass: z.string(), pass: z.string().optional(),
}); });
export const connectionSchema = z.discriminatedUnion("type", [postgresSchema]); 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<typeof serverBackupSchema>;
export const createServerSchema = z.object({ export const createServerSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
ssh: sshSchema, ssh: sshSchema,
connection: connectionSchema, connection: connectionSchema,
isActive: z.boolean().optional(), isActive: z.boolean().optional(),
databases: z.string().array().min(1), databases: z.string().array().min(1),
backup: serverBackupSchema.optional().nullable(),
}); });
export type CreateServerSchema = z.infer<typeof createServerSchema>; export type CreateServerSchema = z.infer<typeof createServerSchema>;
export const updateServerSchema = createServerSchema.partial();
export type UpdateServerSchema = z.infer<typeof updateServerSchema>;
export const checkServerSchema = z.object({ export const checkServerSchema = z.object({
ssh: sshSchema, ssh: sshSchema,
connection: connectionSchema, connection: connectionSchema,

View File

@ -1,5 +1,5 @@
import db from "../db"; import db from "../db";
import { backupModel, serverModel } from "../db/models"; import { backupModel, databaseModel, serverModel } from "../db/models";
import type { import type {
CreateBackupSchema, CreateBackupSchema,
GetAllBackupQuery, GetAllBackupQuery,
@ -8,6 +8,7 @@ import type {
import { and, count, desc, eq, inArray } from "drizzle-orm"; import { and, count, desc, eq, inArray } from "drizzle-orm";
import DatabaseService from "./database.service"; import DatabaseService from "./database.service";
import { HTTPException } from "hono/http-exception"; import { HTTPException } from "hono/http-exception";
import ServerService from "./server.service";
export default class BackupService { export default class BackupService {
private databaseService = new DatabaseService(); private databaseService = new DatabaseService();
@ -58,6 +59,7 @@ export default class BackupService {
* Queue new backup * Queue new backup
*/ */
async create(data: CreateBackupSchema) { async create(data: CreateBackupSchema) {
if (data.databaseId) {
const database = await this.databaseService.getOrFail(data.databaseId); const database = await this.databaseService.getOrFail(data.databaseId);
await this.checkPendingBackup(database.id); await this.checkPendingBackup(database.id);
@ -71,6 +73,34 @@ export default class BackupService {
.returning(); .returning();
return result; 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: d.serverId,
databaseId: d.id,
}));
const result = await db
.insert(backupModel)
.values(values as never)
.returning();
return result;
}
return null;
} }
async restore(data: RestoreBackupSchema) { async restore(data: RestoreBackupSchema) {

View File

@ -1,8 +1,12 @@
import db from "../db"; import db from "../db";
import { databaseModel, serverModel, type ServerModel } from "../db/models"; import { databaseModel, serverModel, type ServerModel } from "../db/models";
import type { CreateServerSchema } from "../schemas/server.schema"; import type {
import { asc, desc, eq } from "drizzle-orm"; CreateServerSchema,
UpdateServerSchema,
} from "../schemas/server.schema";
import { and, asc, desc, eq, ne } from "drizzle-orm";
import { HTTPException } from "hono/http-exception"; import { HTTPException } from "hono/http-exception";
import dayjs from "dayjs";
export default class ServerService { export default class ServerService {
async getAll() { async getAll() {
@ -61,6 +65,7 @@ export default class ServerService {
type: data.connection.type, type: data.connection.type,
connection: data.connection ? JSON.stringify(data.connection) : null, connection: data.connection ? JSON.stringify(data.connection) : null,
ssh: data.ssh ? JSON.stringify(data.ssh) : null, ssh: data.ssh ? JSON.stringify(data.ssh) : null,
nextBackup: this.calculateNextBackup(data as never),
}; };
// Create server // Create server
@ -81,6 +86,47 @@ export default class ServerService {
}); });
} }
async update(
server: Awaited<ReturnType<typeof this.getOrFail>>,
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<T extends Pick<ServerModel, "connection" | "ssh">>(data: T) { parse<T extends Pick<ServerModel, "connection" | "ssh">>(data: T) {
const result = { const result = {
...data, ...data,
@ -90,4 +136,44 @@ export default class ServerService {
return result; return result;
} }
calculateNextBackup(
server: Pick<ServerModel, "backup">,
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");
}
} }

View File

@ -4,7 +4,7 @@ export type PostgresConfig = {
type: "postgres"; type: "postgres";
host: string; host: string;
user: string; user: string;
pass: string; pass?: string;
port?: number; port?: number;
}; };

Binary file not shown.

View File

@ -18,11 +18,12 @@
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-select": "^2.0.0", "@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"hono": "^4.3.4", "hono": "4.3.5",
"lucide-react": "^0.378.0", "lucide-react": "^0.378.0",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "^18.2.0", "react": "^18.2.0",

View File

@ -3,10 +3,17 @@ import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import { cn } from "@/lib/utils"; 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< const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>, React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> CheckboxProps
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
ref={ref} ref={ref}
@ -25,4 +32,51 @@ const Checkbox = React.forwardRef<
)); ));
Checkbox.displayName = CheckboxPrimitive.Root.displayName; Checkbox.displayName = CheckboxPrimitive.Root.displayName;
type CheckboxFieldProps<TValues extends FieldValues> = Omit<
CheckboxProps,
"form"
> & {
form: UseFormReturn<TValues>;
name: FieldPath<TValues>;
label?: string;
fieldClassName?: string;
};
const CheckboxField = <TValues extends FieldValues>({
form,
name,
label,
fieldClassName,
className,
...props
}: CheckboxFieldProps<TValues>) => {
return (
<FormControl
form={form}
name={name}
className={className}
render={({ field, id }) => (
<div className="flex items-center gap-2">
<Checkbox
id={id}
className={fieldClassName}
{...field}
checked={field.value}
onCheckedChange={field.onChange}
{...props}
/>
{label ? (
<Label htmlFor={id} className="cursor-pointer">
{label}
</Label>
) : null}
</div>
)}
/>
);
};
export default CheckboxField;
export { Checkbox }; export { Checkbox };

View File

@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-10 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", "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 className
)} )}
{...props} {...props}
@ -200,7 +200,9 @@ const SelectField = <T extends FieldValues>({
name={name} name={name}
label={label} label={label}
className={className} className={className}
render={({ field, id }) => <Select id={id} {...field} {...props} />} render={({ field, id }) => (
<Select id={id} {...field} value={String(field.value)} {...props} />
)}
/> />
); );
SelectField.displayName = "SelectField"; SelectField.displayName = "SelectField";

View File

@ -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<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-slate-100 p-1 text-slate-500 dark:bg-slate-800 dark:text-slate-400",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-slate-950 data-[state=active]:shadow-sm dark:ring-offset-slate-950 dark:focus-visible:ring-slate-300 dark:data-[state=active]:bg-slate-950 dark:data-[state=active]:text-slate-50",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 dark:ring-offset-slate-950 dark:focus-visible:ring-slate-300",
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -1,5 +1,5 @@
import { ClientResponse, hc } from "hono/client"; import { ClientResponse, hc } from "hono/client";
import type { AppRouter } from "../../../backend/src/routers"; import type { AppRouter } from "@backend/routers";
const api = hc<AppRouter>("http://localhost:3000/"); const api = hc<AppRouter>("http://localhost:3000/");

View File

@ -1,6 +1,20 @@
import { type ClassValue, clsx } from "clsx"; import { type ClassValue, clsx } from "clsx";
import { toast } from "sonner"; import { toast } from "sonner";
import { twMerge } from "tailwind-merge"; 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[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));

View File

@ -3,10 +3,10 @@ import Button from "@/components/ui/button";
import api, { parseJson } from "@/lib/api"; import api, { parseJson } from "@/lib/api";
import { useQuery } from "react-query"; import { useQuery } from "react-query";
import ServerList from "../../servers/components/server-list"; 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 { initialServerData } from "@/pages/servers/schema";
import PageTitle from "@/components/ui/page-title"; import PageTitle from "@/components/ui/page-title";
import { addServerDlg } from "@/pages/servers/stores"; import { serverFormDlg } from "@/pages/servers/stores";
const ServerSection = () => { const ServerSection = () => {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
@ -27,7 +27,7 @@ const ServerSection = () => {
<p>No server added.</p> <p>No server added.</p>
<Button <Button
className="mt-2" className="mt-2"
onClick={() => addServerDlg.onOpen({ ...initialServerData })} onClick={() => serverFormDlg.onOpen({ ...initialServerData })}
> >
Add Server Add Server
</Button> </Button>
@ -36,7 +36,7 @@ const ServerSection = () => {
<ServerList items={data} /> <ServerList items={data} />
)} )}
<AddServerDialog /> <ServerFormDialog />
</section> </section>
); );
}; };

View File

@ -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 (
<Dialog open={isOpen} onOpenChange={addServerDlg.setOpen}>
<DialogContent>
<Form form={form} onSubmit={onSubmit}>
<DialogTitle>Add Server</DialogTitle>
<DialogBody>
<div className="grid grid-cols-2 gap-3 mt-3">
<InputField form={form} name="name" label="Server Name" />
<SelectField
form={form}
name="connection.type"
options={connectionTypes}
label="Type"
/>
{type === "postgres" && (
<>
<InputField
form={form}
name="connection.host"
label="Hostname"
/>
<InputField
form={form}
type="number"
name="connection.port"
label="Port"
/>
<InputField
form={form}
name="connection.user"
label="Username"
/>
<InputField
form={form}
type="password"
name="connection.pass"
label="Password"
/>
</>
)}
</div>
<ErrorAlert error={checkConnection.error} className="mt-4" />
<Button
className="mt-4"
variant="outline"
size="sm"
icon={IoInformationCircleOutline}
isLoading={checkConnection.isLoading}
onClick={() => checkConnection.mutate()}
>
Check Connection
</Button>
<SelectDatabase databases={checkConnection.data?.databases} />
</DialogBody>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={addServerDlg.onClose}>
Cancel
</Button>
<Button
type="submit"
disabled={!databases.length || !checkConnection.data?.success}
isLoading={createServer.isLoading}
>
Submit
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};
export const SelectDatabase = ({ databases }: { databases?: string[] }) => {
const form = useFormContext<CreateServerSchema>();
if (databases == null) {
return null;
}
return (
<div className="mt-4 border-t pt-4">
<div className="flex items-center gap-2">
<p className="font-medium text-sm flex-1">Database:</p>
<Checkbox
id="select-all-db"
onCheckedChange={(checked) => {
if (checked) {
form.setValue("databases", databases);
} else {
form.setValue("databases", []);
}
}}
/>
<Label htmlFor="select-all-db" className="cursor-pointer">
Select All
</Label>
</div>
{!databases.length && <p className="text-gray-500">No database exist.</p>}
<Controller
control={form.control}
name="databases"
render={({ field: { value, onChange } }) => (
<div className="grid sm:grid-cols-2 gap-2 mt-2 max-h-[260px] overflow-y-auto">
{databases.map((name) => (
<div
key={name}
className="flex gap-2 items-center border rounded px-3 hover:bg-gray-50 transition-colors"
>
<Checkbox
id={`db-${name}`}
checked={value.includes(name)}
onCheckedChange={(checked) => {
onChange(
checked
? [...value, name]
: value.filter((i) => i !== name)
);
}}
/>
<Label
htmlFor={`db-${name}`}
className="flex-1 py-3 block cursor-pointer"
>
{name}
</Label>
</div>
))}
</div>
)}
/>
</div>
);
};
export default AddServerDialog;

View File

@ -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<CreateServerSchema>();
const scheduled = useWatch({
control: form.control,
name: "backup.scheduled",
});
const interval = useWatch({
control: form.control,
name: "backup.interval",
});
return (
<TabsContent value="backup" className="mt-4">
<CheckboxField form={form} name="backup.compress" label="Compressed" />
<CheckboxField
className="mt-4"
form={form}
name="backup.scheduled"
label="Schedule Backup"
/>
{scheduled && (
<div className="ml-6 mt-2 p-2 rounded bg-gray-50 grid grid-cols-1 md:grid-cols-2 gap-2">
<div className="flex items-center gap-2">
<p className="flex-1">every</p>
<InputField
form={form}
name="backup.every"
type="number"
className="w-2/3"
/>
</div>
<SelectField
form={form}
name="backup.interval"
options={intervalList}
/>
{["day", "week", "month", "year"].includes(interval) && (
<div className="flex items-center gap-2">
<p className="flex-1">at</p>
<InputField
type="time"
form={form}
name="backup.time"
className="w-2/3"
/>
</div>
)}
{["week", "month"].includes(interval) && (
<div className="flex items-center gap-2">
<p className="flex-1">on</p>
<SelectField
form={form}
name="backup.day"
options={dayOfWeekList}
className="w-2/3"
/>
</div>
)}
{interval === "year" && (
<div className="flex items-center gap-2">
<p className="flex-1">on</p>
<SelectField
form={form}
name="backup.month"
options={monthList}
className="w-2/3"
/>
</div>
)}
</div>
)}
</TabsContent>
);
};
export default BackupTab;

View File

@ -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<CreateServerSchema>();
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 (
<TabsContent value="connection">
<div className="grid grid-cols-2 gap-3 mt-3">
<InputField form={form} name="name" label="Server Name" />
<SelectField
form={form}
name="connection.type"
options={connectionTypes}
label="Type"
/>
{type === "postgres" && (
<>
<InputField form={form} name="connection.host" label="Hostname" />
<InputField
form={form}
type="number"
name="connection.port"
label="Port"
/>
<InputField form={form} name="connection.user" label="Username" />
<InputField
form={form}
type="password"
name="connection.pass"
label="Password"
/>
</>
)}
</div>
<ErrorAlert error={checkConnection.error} className="mt-4" />
<Button
className="mt-4"
variant="outline"
size="sm"
icon={IoInformationCircleOutline}
isLoading={checkConnection.isLoading}
onClick={() => checkConnection.mutate()}
>
Check Connection
</Button>
<SelectDatabase databases={checkConnection.data?.databases} />
</TabsContent>
);
};
export const SelectDatabase = ({ databases }: { databases?: string[] }) => {
const form = useFormContext<CreateServerSchema>();
if (databases == null) {
return null;
}
return (
<div className="mt-4 border-t pt-4">
<div className="flex items-center gap-2">
<p className="font-medium text-sm flex-1">Database:</p>
<Checkbox
id="select-all-db"
onCheckedChange={(checked) => {
if (checked) {
form.setValue("databases", databases);
} else {
form.setValue("databases", []);
}
}}
/>
<Label htmlFor="select-all-db" className="cursor-pointer">
Select All
</Label>
</div>
{!databases.length && <p className="text-gray-500">No database exist.</p>}
<Controller
control={form.control}
name="databases"
render={({ field: { value, onChange } }) => (
<div className="grid sm:grid-cols-2 gap-2 mt-2 max-h-[260px] overflow-y-auto">
{databases.map((name) => (
<div
key={name}
className="flex gap-2 items-center border rounded px-3 hover:bg-gray-50 transition-colors"
>
<Checkbox
id={`db-${name}`}
checked={value.includes(name)}
onCheckedChange={(checked) => {
onChange(
checked
? [...value, name]
: value.filter((i) => i !== name)
);
}}
/>
<Label
htmlFor={`db-${name}`}
className="flex-1 py-3 block cursor-pointer"
>
{name}
</Label>
</div>
))}
</div>
)}
/>
</div>
);
};
export default ConnectionTab;

View File

@ -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 (
<Dialog open={isOpen} onOpenChange={serverFormDlg.setOpen}>
<DialogContent>
<Form form={form} onSubmit={onSubmit}>
<DialogTitle>{`${data?.id ? "Edit" : "Add"} Server`}</DialogTitle>
<DialogBody className="min-h-[300px]">
<Tabs defaultValue="connection" className="mt-4">
<TabsList>
<TabsTrigger value="connection">Connection</TabsTrigger>
<TabsTrigger value="backup">Backup</TabsTrigger>
</TabsList>
<ConnectionTab />
<BackupTab />
</Tabs>
</DialogBody>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={serverFormDlg.onClose}>
Cancel
</Button>
<Button
type="submit"
disabled={!databases.length}
isLoading={saveServer.isLoading}
>
Submit
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};
export default ServerFormDialog;

View File

@ -3,8 +3,8 @@ import Button from "@/components/ui/button";
import api, { parseJson } from "@/lib/api"; import api, { parseJson } from "@/lib/api";
import { useQuery } from "react-query"; import { useQuery } from "react-query";
import ServerList from "./components/server-list"; import ServerList from "./components/server-list";
import AddServerDialog from "./components/add-server-dialog"; import ServerFormDialog from "./components/server-form-dialog";
import { addServerDlg } from "./stores"; import { serverFormDlg } from "./stores";
import { initialServerData } from "./schema"; import { initialServerData } from "./schema";
import PageTitle from "@/components/ui/page-title"; import PageTitle from "@/components/ui/page-title";
@ -19,7 +19,7 @@ const ServerPage = () => {
<div className="flex items-center gap-2 mt-2 md:mt-4"> <div className="flex items-center gap-2 mt-2 md:mt-4">
<PageTitle className="flex-1">Servers</PageTitle> <PageTitle className="flex-1">Servers</PageTitle>
<Button onClick={() => addServerDlg.onOpen({ ...initialServerData })}> <Button onClick={() => serverFormDlg.onOpen({ ...initialServerData })}>
Add Server Add Server
</Button> </Button>
</div> </div>
@ -33,7 +33,7 @@ const ServerPage = () => {
<p>No server added.</p> <p>No server added.</p>
<Button <Button
className="mt-2" className="mt-2"
onClick={() => addServerDlg.onOpen({ ...initialServerData })} onClick={() => serverFormDlg.onOpen({ ...initialServerData })}
> >
Add Server Add Server
</Button> </Button>
@ -42,7 +42,7 @@ const ServerPage = () => {
<ServerList items={data} /> <ServerList items={data} />
)} )}
<AddServerDialog /> <ServerFormDialog />
</main> </main>
); );
}; };

View File

@ -1,5 +1,6 @@
import { SelectOption } from "@/components/ui/select"; 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[] = [ 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<typeof serverFormSchema>;
export const initialServerData: ServerFormSchema = {
name: "", name: "",
connection: { connection: {
type: "postgres", type: "postgres",
@ -18,4 +27,13 @@ export const initialServerData: CreateServerSchema = {
pass: "", pass: "",
}, },
databases: [], databases: [],
backup: {
compress: true,
scheduled: false,
every: 1,
interval: "day",
time: "01:00",
day: 0,
month: 0,
},
}; };

View File

@ -1,4 +1,4 @@
import { createDisclosureStore } from "@/lib/disclosure"; import { createDisclosureStore } from "@/lib/disclosure";
import { initialServerData } from "../servers/schema"; import { initialServerData } from "../servers/schema";
export const addServerDlg = createDisclosureStore(initialServerData); export const serverFormDlg = createDisclosureStore(initialServerData);

View File

@ -62,7 +62,7 @@ const BackupSection = ({ databases }: BackupSectionProps) => {
onChange={(i) => setQuery({ databaseId: i })} onChange={(i) => setQuery({ databaseId: i })}
value={query.databaseId} value={query.databaseId}
placeholder="Select Database" placeholder="Select Database"
className="min-w-[120px]" className="min-w-[120px] w-auto"
/> />
</div> </div>
<Card className="mt-4 px-2 flex-1"> <Card className="mt-4 px-2 flex-1">

View File

@ -1,7 +1,7 @@
import BackButton from "@/components/ui/back-button"; import BackButton from "@/components/ui/back-button";
import PageTitle from "@/components/ui/page-title"; import PageTitle from "@/components/ui/page-title";
import api, { parseJson } from "@/lib/api"; 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 { useQuery } from "react-query";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import ConnectionStatus, { import ConnectionStatus, {
@ -10,6 +10,17 @@ import ConnectionStatus, {
import Card from "@/components/ui/card"; import Card from "@/components/ui/card";
import BackupSection from "./components/backups-section"; import BackupSection from "./components/backups-section";
import DatabaseSection from "./components/databases-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 ViewServerPage = () => {
const id = useParams().id!; const id = useParams().id!;
@ -36,7 +47,7 @@ const ViewServerPage = () => {
<BackButton to="/servers" /> <BackButton to="/servers" />
<PageTitle>Server Information</PageTitle> <PageTitle>Server Information</PageTitle>
<Card className="mt-4 p-4 md:p-8"> <Card className="mt-4 p-4 md:p-8 relative">
<IoServer className="text-4xl text-gray-600" /> <IoServer className="text-4xl text-gray-600" />
<div className="mt-2 flex items-center"> <div className="mt-2 flex items-center">
<p className="text-xl text-gray-800">{data?.name}</p> <p className="text-xl text-gray-800">{data?.name}</p>
@ -52,14 +63,63 @@ const ViewServerPage = () => {
<ConnectionStatus status={check.data?.success} error={check.error} /> <ConnectionStatus status={check.data?.success} error={check.error} />
<p>{getConnectionLabel(check.data?.success, check.error)}</p> <p>{getConnectionLabel(check.data?.success, check.error)}</p>
</div> </div>
{data != null && <ServerMenuBtn data={data} />}
</Card> </Card>
<div className="grid grid-cols-1 lg:grid-cols-2 items-stretch gap-4 w-full overflow-hidden"> <div className="grid grid-cols-1 lg:grid-cols-2 items-stretch gap-4 w-full overflow-hidden">
<DatabaseSection databases={data?.databases || []} /> <DatabaseSection databases={data?.databases || []} />
<BackupSection databases={data?.databases || []} /> <BackupSection databases={data?.databases || []} />
</div> </div>
<ServerFormDialog />
</main> </main>
); );
}; };
const ServerMenuBtn = ({ data }: { data: GetServerResult }) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
icon={IoEllipsisVertical}
variant="ghost"
className="absolute right-4 top-4"
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() =>
serverFormDlg.onOpen({
...data,
databases: data.databases.map((i) => i.name),
})
}
>
Edit Server
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
try {
const res = await api.backups.$post({
json: { serverId: data.id },
});
await parseJson(res);
toast.success("Queueing server backup success!");
} catch (err) {
toast.error(
"Failed to trigger server backup! " + (err as Error).message
);
}
}}
>
Backup All Database
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
export default ViewServerPage; export default ViewServerPage;

View File

@ -0,0 +1,7 @@
import api from "@/lib/api";
import type { InferResponseType } from "hono/client";
const getServerById = api.servers[":id"].$get;
export type GetServerResult = NonNullable<
InferResponseType<typeof getServerById>
>;

View File

@ -1,8 +1,6 @@
import { TableColumn } from "@/components/ui/data-table"; import { TableColumn } from "@/components/ui/data-table";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import BackupButton from "../components/backup-button"; import BackupButton from "../components/backup-button";
import { copyToClipboard, formatBytes } from "@/lib/utils"; import { copyToClipboard, date, formatBytes } from "@/lib/utils";
import BackupStatus from "../components/backup-status"; import BackupStatus from "../components/backup-status";
import { queryClient } from "@/lib/queryClient"; import { queryClient } from "@/lib/queryClient";
import Button from "@/components/ui/button"; import Button from "@/components/ui/button";
@ -17,8 +15,6 @@ import { confirmDlg } from "@/components/containers/confirm-dialog";
import api, { parseJson } from "@/lib/api"; import api, { parseJson } from "@/lib/api";
import { toast } from "sonner"; import { toast } from "sonner";
dayjs.extend(relativeTime);
export type DatabaseType = { export type DatabaseType = {
id: string; id: string;
name: string; name: string;
@ -36,8 +32,8 @@ export const databaseColumns: TableColumn<DatabaseType>[] = [
{ {
name: "Last Backup", name: "Last Backup",
selector: (i) => selector: (i) =>
i.lastBackupAt ? dayjs(i.lastBackupAt).format("YYYY-MM-DD HH:mm") : "", i.lastBackupAt ? date(i.lastBackupAt).format("YYYY-MM-DD HH:mm") : "",
cell: (i) => (i.lastBackupAt ? dayjs(i.lastBackupAt).fromNow() : "never"), cell: (i) => (i.lastBackupAt ? date(i.lastBackupAt).fromNow() : "never"),
}, },
{ {
selector: (i) => i.id, selector: (i) => i.id,
@ -136,12 +132,16 @@ export const backupsColumns: TableColumn<BackupType>[] = [
}, },
{ {
name: "Timestamp", name: "Timestamp",
selector: (i) => dayjs(i.createdAt).format("YYYY-MM-DD HH:mm"), selector: (i) => date(i.createdAt).format("YYYY-MM-DD HH:mm"),
cell: (i) => { cell: (i) => {
const diff = dayjs().diff(dayjs(i.createdAt), "days"); const diff = date().diff(date(i.createdAt), "days");
return diff < 3 return (
? dayjs(i.createdAt).fromNow() <p className="whitespace-nowrap truncate">
: dayjs(i.createdAt).format("DD/MM/YY HH:mm"); {diff < 3
? date(i.createdAt).fromNow()
: date(i.createdAt).format("DD/MM/YY HH:mm")}
</p>
);
}, },
}, },
{ {