mirror of
https://github.com/khairul169/db-backup-tool.git
synced 2025-04-28 16:49:34 +07:00
feat: add recurring backup scheduler
This commit is contained in:
parent
3d7508816f
commit
449ba1b9d0
@ -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({
|
||||||
|
@ -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"
|
||||||
|
@ -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` (
|
@ -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": {},
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -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],
|
||||||
|
@ -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;
|
||||||
|
55
backend/src/schedulers/backup-scheduler.ts
Normal file
55
backend/src/schedulers/backup-scheduler.ts
Normal 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);
|
||||||
|
};
|
@ -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();
|
||||||
};
|
};
|
||||||
|
@ -12,9 +12,14 @@ 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>;
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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) {
|
||||||
|
@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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.
@ -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",
|
||||||
|
@ -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 };
|
||||||
|
@ -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";
|
||||||
|
53
frontend/src/components/ui/tabs.tsx
Normal file
53
frontend/src/components/ui/tabs.tsx
Normal 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 };
|
@ -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/");
|
||||||
|
|
||||||
|
@ -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));
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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;
|
|
124
frontend/src/pages/servers/components/server-form-backup-tab.tsx
Normal file
124
frontend/src/pages/servers/components/server-form-backup-tab.tsx
Normal 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;
|
@ -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;
|
93
frontend/src/pages/servers/components/server-form-dialog.tsx
Normal file
93
frontend/src/pages/servers/components/server-form-dialog.tsx
Normal 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;
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
@ -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);
|
||||||
|
@ -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">
|
||||||
|
@ -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;
|
||||||
|
7
frontend/src/pages/servers/view/schema.ts
Normal file
7
frontend/src/pages/servers/view/schema.ts
Normal 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>
|
||||||
|
>;
|
@ -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>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
Loading…
x
Reference in New Issue
Block a user