feat: backup and restore logs modal view

This commit is contained in:
Charles GTE
2026-06-06 22:46:36 +02:00
parent 1e3f112d9c
commit e1e4d67114
22 changed files with 9294 additions and 28 deletions
@@ -9,6 +9,7 @@ import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {BackupModalProvider} from "@/features/database/backup-modal-context";
import {DatabaseContent} from "@/features/database/database-content";
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
import {LogsModalProvider} from "@/features/logs/logs-modal-context";
export default async function RoutePage(props: PageParams<{
projectId: string;
@@ -34,7 +35,7 @@ export default async function RoutePage(props: PageParams<{
project: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true
storagePolicies: true,
}
});
@@ -50,7 +51,8 @@ export default async function RoutePage(props: PageParams<{
with: {
storageChannel: true
}
}
},
logs: true
},
orderBy: (b, {desc}) => [desc(b.createdAt)],
});
@@ -84,7 +86,7 @@ export default async function RoutePage(props: PageParams<{
notFound();
}
const databaseHealthLogs = dbItem ? await getHealthLast12hLogs({ id: dbItem.id }) : []
const databaseHealthLogs = dbItem ? await getHealthLast12hLogs({id: dbItem.id}) : []
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
@@ -93,23 +95,25 @@ export default async function RoutePage(props: PageParams<{
return (
<Page>
<BackupModalProvider>
<DatabaseContent
activeMember={activeMember}
settings={settings}
database={dbItem}
databaseHealthLogs={databaseHealthLogs}
isAlreadyRestore={isAlreadyRestore}
restorations={restorations}
backups={backups}
totalBackups={totalBackups}
availableBackups={availableBackups}
successRate={successRate}
organizationId={organization.id}
activeOrganizationChannels={[]}
activeOrganizationStorageChannels={[]}
/>
</BackupModalProvider>
<LogsModalProvider>
<BackupModalProvider>
<DatabaseContent
activeMember={activeMember}
settings={settings}
database={dbItem}
databaseHealthLogs={databaseHealthLogs}
isAlreadyRestore={isAlreadyRestore}
restorations={restorations}
backups={backups}
totalBackups={totalBackups}
availableBackups={availableBackups}
successRate={successRate}
organizationId={organization.id}
activeOrganizationChannels={[]}
activeOrganizationStorageChannels={[]}
/>
</BackupModalProvider>
</LogsModalProvider>
</Page>
);
}
+30 -1
View File
@@ -9,6 +9,7 @@ import {eventEmitter} from "@/lib/event";
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers";
import {EventKind} from "@/features/notifications/notifications.types";
import {logger} from "@/lib/logger";
import {JobLogEntry} from "@/features/logs/types";
const log = logger.child({module: "api/agent/backup/route"});
@@ -22,6 +23,8 @@ export type BodyPatch = {
status: "success" | "failed"
size: number
generatedId: string
logs: JobLogEntry[]
durationMs: number
}
export const POST = withAgentCheck(async (request: Request, {params, agent}: {
@@ -30,6 +33,7 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
}) => {
try {
const body: BodyPost = await request.json();
const method = body.method
const database = await getDatabaseOrThrow(body.generatedId);
@@ -127,12 +131,37 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({
status: status,
fileSize: backupSize
fileSize: backupSize,
durationMs: body.durationMs
}))
.where(eq(drizzleDb.schemas.backup.id, backup.id))
.returning();
const logsToInsert = body.logs.map((entry) => ({
backupId: backup.id,
restorationId: null,
loggedAt: new Date(entry.timestamp),
entryType: entry.type,
level: entry.level,
message: entry.message,
command: entry.command ?? null,
output: entry.output ?? null,
exitCode: entry.exit_code ?? null,
durationMs: entry.duration_ms ?? null,
}));
if (logsToInsert.length > 0) {
await dbClient
.insert(drizzleDb.schemas.jobLog)
.values(logsToInsert);
}
eventEmitter.emit('modification', {update: true});
await sendNotificationsBackupRestore(database, status == "failed" ? "error_backup" : "success_backup" as EventKind);
+3 -1
View File
@@ -17,6 +17,7 @@ import * as storagePolicy from "@/db/schema/13_storage-policy";
import * as backupStorage from "@/db/schema/14_storage-backup";
import * as healthcheckLog from "@/db/schema/15_healthcheck-log";
import * as apiKey from "@/db/schema/16_apikey";
import * as jobLog from "@/db/schema/17_job-log";
const log = logger.child({module: "db"});
@@ -53,7 +54,8 @@ export const schemas = {
...storagePolicy,
...backupStorage,
...healthcheckLog,
...apiKey
...apiKey,
...jobLog
};
export const db = drizzle({
+11
View File
@@ -0,0 +1,11 @@
CREATE TABLE "job_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"backup_id" uuid,
"restoration_id" uuid,
"updated_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"deleted_at" timestamp
);
--> statement-breakpoint
ALTER TABLE "job_log" ADD CONSTRAINT "job_log_backup_id_backups_id_fk" FOREIGN KEY ("backup_id") REFERENCES "public"."backups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "job_log" ADD CONSTRAINT "job_log_restoration_id_restorations_id_fk" FOREIGN KEY ("restoration_id") REFERENCES "public"."restorations"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,10 @@
CREATE TYPE "public"."job_log_entry_type" AS ENUM('log', 'command');--> statement-breakpoint
CREATE TYPE "public"."job_log_level" AS ENUM('debug', 'info', 'warn', 'error');--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "logged_at" timestamp with time zone NOT NULL;--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "entry_type" "job_log_entry_type" NOT NULL;--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "level" "job_log_level" NOT NULL;--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "message" text NOT NULL;--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "command" text;--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "output" text;--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "exit_code" integer;--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "duration_ms" bigint;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "backups" ADD COLUMN "duration_ms" bigint;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -407,6 +407,27 @@
"when": 1779698258492,
"tag": "0057_cooing_nocturne",
"breakpoints": true
},
{
"idx": 58,
"version": "7",
"when": 1780769342681,
"tag": "0058_slim_annihilus",
"breakpoints": true
},
{
"idx": 59,
"version": "7",
"when": 1780770010009,
"tag": "0059_past_fabian_cortez",
"breakpoints": true
},
{
"idx": 60,
"version": "7",
"when": 1780771191196,
"tag": "0060_shiny_sersi",
"breakpoints": true
}
]
}
+10 -1
View File
@@ -9,6 +9,7 @@ import {timestamps} from "@/db/schema/00_common";
import {AlertPolicy, alertPolicy} from "@/db/schema/10_alert-policy";
import {StoragePolicy, storagePolicy} from "@/db/schema/13_storage-policy";
import {BackupStorage, backupStorage} from "@/db/schema/14_storage-backup";
import {JobLog, jobLog} from "@/db/schema/17_job-log";
export const database = pgTable("databases", {
id: uuid("id").primaryKey().defaultRandom(),
@@ -38,6 +39,7 @@ export const backup = pgTable(
status: statusEnum("status").default("waiting").notNull(),
file: text("file"),
fileSize: bigint("file_size", { mode: "number" }),
durationMs: bigint("duration_ms", { mode: "number" }),
databaseId: uuid("database_id")
.notNull()
.references(() => database.id, {onDelete: "cascade"}),
@@ -94,12 +96,15 @@ export const backupRelations = relations(backup, ({one, many}) => ({
database: one(database, {fields: [backup.databaseId], references: [database.id]}),
restorations: many(restoration),
storages: many(backupStorage),
logs: many(jobLog),
}));
export const restorationRelations = relations(restoration, ({one}) => ({
export const restorationRelations = relations(restoration, ({one, many}) => ({
backup: one(backup, {fields: [restoration.backupId], references: [backup.id]}),
database: one(database, {fields: [restoration.databaseId], references: [database.id]}),
backupStorage: one(backupStorage, {fields: [restoration.backupStorageId], references: [backupStorage.id]}),
logs: many(jobLog),
}));
@@ -131,12 +136,16 @@ export type DatabaseWith = Database & {
retentionPolicy?: RetentionPolicy | null;
alertPolicies?: AlertPolicy[] | null;
storagePolicies?: StoragePolicy[] | null;
logs?: JobLog[] | null;
};
export type BackupWith = Backup & {
restorations?: Restoration[] | null;
storages?: BackupStorage[] | null;
logs?: JobLog[] | null;
};
+67
View File
@@ -0,0 +1,67 @@
import {pgTable, uuid, text, integer, pgEnum, bigint, timestamp} from "drizzle-orm/pg-core";
import { timestamps } from "@/db/schema/00_common";
import {backup, restoration} from "@/db/schema/07_database";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
import {relations} from "drizzle-orm";
export const jobLogLevelEnum = pgEnum("job_log_level", [
"debug",
"info",
"warn",
"error",
]);
export const jobLogEntryTypeEnum = pgEnum("job_log_entry_type", [
"log",
"command",
]);
export const jobLog = pgTable(
"job_log",
{
id: uuid("id").primaryKey().defaultRandom(),
backupId: uuid("backup_id").references(() => backup.id, {
onDelete: "cascade",
}),
restorationId: uuid("restoration_id").references(() => restoration.id, {
onDelete: "cascade",
}),
loggedAt: timestamp("logged_at", { withTimezone: true }).notNull(),
entryType: jobLogEntryTypeEnum("entry_type").notNull(),
level: jobLogLevelEnum("level").notNull(),
message: text("message").notNull(),
command: text("command"),
output: text("output"),
exitCode: integer("exit_code"),
durationMs: bigint("duration_ms", { mode: "number" }),
...timestamps,
},
);
export const jobLogRelations = relations(jobLog, ({ one }) => ({
backup: one(backup, {
fields: [jobLog.backupId],
references: [backup.id],
}),
restoration: one(restoration, {
fields: [jobLog.restorationId],
references: [restoration.id],
}),
}));
export const jobLogSchema = createSelectSchema(jobLog);
export type JobLog = z.infer<typeof jobLogSchema>;
+20 -3
View File
@@ -2,23 +2,24 @@
import {ColumnDef} from "@tanstack/react-table";
import {StatusBadge} from "@/components/common/status-badge";
import {Backup, DatabaseWith} from "@/db/schema/07_database";
import {Backup, BackupWith, DatabaseWith} from "@/db/schema/07_database";
import {Setting} from "@/db/schema/01_setting";
import {cn} from "@/lib/utils";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
import {MemberWithUser} from "@/db/schema/03_organization";
import {formatLocalizedDate} from "@/utils/date-formatting";
import {formatBytes} from "@/utils/text";
import {formatBytes, formatDuration} from "@/utils/text";
import {DatabaseActionsCell} from "@/features/database/backup-actions-cell";
import { Badge as BadgeC } from "@/components/ui/badge";
import {backupOnly} from "@/features/database/database-tabs";
import {LogsModalTrigger} from "@/features/logs/logs-modal-trigger";
export function backupColumns(
isAlreadyRestore: boolean,
settings: Setting,
database: DatabaseWith,
activeMember: MemberWithUser
): ColumnDef<Backup>[] {
): ColumnDef<BackupWith>[] {
const isBackupOnly = backupOnly.some((type) => database.dbms === type)
@@ -85,6 +86,14 @@ export function backupColumns(
return formatBytes(row.getValue("fileSize"))
},
},
{
accessorKey: "durationMs",
header: "Duration",
cell: ({row}) => {
const durationMs = row.getValue("durationMs");
return durationMs ? formatDuration(row.getValue("durationMs")) : "-"
},
},
{
accessorKey: "createdAt",
header: "Created At",
@@ -99,6 +108,14 @@ export function backupColumns(
return <StatusBadge status={row.getValue("status")}/>;
},
},
{
accessorKey: "logs",
header: "Logs",
cell: ({row}) => {
const logs = row.original.logs ?? [];
return <LogsModalTrigger logs={logs}/>;
},
},
{
id: "actions",
cell: ({row}) => <DatabaseActionsCell isAlreadyRestore={isAlreadyRestore} activeMember={activeMember} backup={row.original} isBackupOnly={isBackupOnly}/>,
@@ -36,7 +36,8 @@ export const getDatabaseDataAction = userAction
with: {
storageChannel: true
}
}
},
logs: true
},
orderBy: (b, {desc}) => [desc(b.createdAt)],
}) as BackupWith[];
@@ -23,6 +23,7 @@ import {BackupButton} from "@/features/database/backup-button";
import {HealthModal} from "@/features/database/health-modal";
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
import {Badge} from "@/components/ui/badge";
import {LogsModal} from "@/features/logs/logs-modal";
export type DatabaseContentProps = {
settings: Setting;
@@ -156,6 +157,7 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
availableBackups={stats.availableBackups}
totalBackups={stats.totalBackups}
/>
<LogsModal/>
<DatabaseBackupActionsModal/>
<DatabaseTabs
activeMember={props.activeMember}
+135
View File
@@ -0,0 +1,135 @@
"use client"
import { useState } from "react"
import { Minus, Plus } from "lucide-react"
import { cn } from "@/lib/utils"
import { LevelType } from "@/features/logs/types"
import { JobLog } from "@/db/schema/17_job-log"
import { formatLocalizedDate } from "@/utils/date-formatting"
import { formatDuration } from "@/utils/text"
const levelLabel: Record<LevelType, string> = {
info: "Info",
debug: "Debug",
error: "Error",
warn: "Warning",
}
function TypeBadge({ entry }: { entry: JobLog }) {
const isCommand = entry.entryType === "command"
const label = isCommand ? "Command" : levelLabel[entry.level]
const styles = isCommand
? "bg-secondary text-secondary-foreground"
: entry.level === "error"
? "bg-destructive/20 text-destructive"
: entry.level === "warn"
? "bg-amber-500/20 text-amber-600 dark:text-amber-300"
: entry.level === "debug"
? "bg-muted text-muted-foreground"
: "bg-sky-500/20 text-sky-600 dark:text-sky-300"
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
styles,
)}
>
{label}
</span>
)
}
export function LogRow({ entry }: { entry: JobLog }) {
const [open, setOpen] = useState(true)
const isCommand = entry.entryType === "command"
const date = formatLocalizedDate(entry.loggedAt)
const accent =
entry.level === "error"
? "bg-destructive"
: entry.level === "warn"
? "bg-amber-500"
: isCommand
? "bg-emerald-500"
: "bg-sky-500"
return (
<div className="relative border-b border-border last:border-b-0">
<div className={cn("absolute left-0 top-0 h-full w-[3px]", accent)} aria-hidden="true" />
<div className="flex flex-col gap-3 py-4 pl-6 pr-4 sm:flex-row sm:items-start sm:gap-4 sm:pr-6">
<span className="shrink-0 font-mono text-sm text-muted-foreground sm:w-[130px] sm:pt-0.5">
{date}
</span>
<span className="shrink-0 sm:w-[90px] sm:pt-0.5">
<TypeBadge entry={entry} />
</span>
<div className="min-w-0 flex-1">
{isCommand ? (
<div className="flex items-start gap-3">
<code className="block flex-1 truncate rounded-md bg-muted px-3 py-1.5 font-mono text-sm text-foreground">
<span className="text-emerald-600 dark:text-emerald-400">$ </span>
{entry.message}
</code>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-label={open ? "Collapse command output" : "Expand command output"}
className="mt-1 shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
{open ? <Minus className="size-4" /> : <Plus className="size-4" />}
</button>
</div>
) : (
<p className="pt-0.5 text-sm text-foreground">{entry.message}</p>
)}
{isCommand && entry.command && open && (
<div className="mt-4">
<div className="overflow-hidden rounded-xl bg-muted ring-1 ring-border">
<div className="max-h-[15rem] overflow-auto px-4 py-3.5">
<code className="block whitespace-pre font-mono text-sm leading-relaxed text-foreground">
<span className="text-emerald-600 dark:text-emerald-400">$ </span>
{entry.command}
</code>
{entry.output ? (
<pre className="mt-3 whitespace-pre font-mono text-sm leading-relaxed text-muted-foreground">
{entry.output}
</pre>
) : null}
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-4 text-sm sm:gap-6">
{entry.exitCode !== undefined && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Exit code:</span>
<span
className={cn(
"inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-semibold",
entry.exitCode === 0
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-300"
: "bg-destructive/20 text-destructive",
)}
>
{entry.exitCode}
</span>
</div>
)}
{entry.durationMs !== null && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Duration:</span>
<span className="font-medium text-foreground">{formatDuration(entry.durationMs)}</span>
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
)
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import {createContext, useContext, useState, ReactNode} from "react";
import {JobLog} from "@/db/schema/17_job-log";
type LogsModalContextType = {
open: boolean;
logs: JobLog[];
openModal: (logs: JobLog[]) => void;
closeModal: () => void;
};
const LogsModalContext = createContext<LogsModalContextType | undefined>(undefined);
export const LogsModalProvider = ({children}: { children: ReactNode }) => {
const [open, setOpen] = useState(false);
const [logs, setLogs] = useState<JobLog[]>([]);
const openModal = (newLogs: JobLog[]) => {
setLogs(newLogs);
setOpen(true);
};
const closeModal = () => {
setOpen(false);
setLogs([]);
};
return (
<LogsModalContext.Provider value={{open, logs, openModal, closeModal}}>
{children}
</LogsModalContext.Provider>
);
};
export const useLogsModal = () => {
const context = useContext(LogsModalContext);
if (!context) throw new Error("useLogsModal must be used within LogsModalProvider");
return context;
};
+19
View File
@@ -0,0 +1,19 @@
import {FileText, Logs} from "lucide-react";
import {JobLog} from "@/db/schema/17_job-log";
import {Button} from "@/components/ui/button";
import {useLogsModal} from "@/features/logs/logs-modal-context";
export type LogsModalTriggerProps = {
logs: JobLog[]
}
export const LogsModalTrigger = ({logs}: LogsModalTriggerProps) => {
const {openModal} = useLogsModal();
return (
<Button disabled={logs.length == 0} variant="outline" size="sm" onClick={()=> {
openModal(logs);
}}>
<FileText />
</Button>
)
}
+41
View File
@@ -0,0 +1,41 @@
"use client"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useLogsModal } from "@/features/logs/logs-modal-context"
import { JobLog } from "@/db/schema/17_job-log"
import { LogRow } from "@/features/logs/log-row-modal"
export const LogsModal = () => {
const { open, logs, closeModal } = useLogsModal()
return (
<Dialog open={open} onOpenChange={closeModal}>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden border-border p-0 sm:max-w-6xl">
<DialogHeader className="shrink-0 border-b border-border px-6 py-5">
<DialogTitle className="text-xl font-bold tracking-tight text-foreground">
Job Logs
</DialogTitle>
</DialogHeader>
<div className="hidden shrink-0 items-center gap-4 border-b border-border bg-card py-3 pl-6 pr-4 sm:flex">
<span className="w-[130px] text-sm font-semibold text-foreground">Date</span>
<span className="w-[90px] text-sm font-semibold text-foreground">Type</span>
<span className="flex-1 text-sm font-semibold text-foreground">Message</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<div>
{logs.map((entry: JobLog) => (
<LogRow key={entry.id} entry={entry} />
))}
</div>
</div>
</DialogContent>
</Dialog>
)
}
+14
View File
@@ -0,0 +1,14 @@
export type LevelType = "info" | "debug" | "error" | "warn";
export type EntryType = "log" | "command";
export interface JobLogEntry {
timestamp: string
type: EntryType
level: LevelType
message: string
command?: string
output?: string
exit_code?: number
duration_ms?: number
}
-1
View File
@@ -1,5 +1,4 @@
import {formatDistanceToNow} from "date-fns";
import {format} from "date-fns";
/**
* Get user's locale and timezone from the browser
+41
View File
@@ -25,4 +25,45 @@ export function formatBytes(bytes: number | null, decimals = 2): string {
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
}
export function formatDuration(ms: number): string {
if (ms == null || Number.isNaN(ms)) return "0 ms";
const totalMs = Math.max(0, Math.floor(ms));
if (totalMs < 1000) {
return `${totalMs} ms`;
}
const totalSeconds = Math.floor(totalMs / 1000);
const seconds = totalSeconds % 60;
if (totalSeconds < 60) {
return `${totalSeconds} s`;
}
const totalMinutes = Math.floor(totalSeconds / 60);
const minutes = totalMinutes % 60;
if (totalMinutes < 60) {
return seconds > 0
? `${totalMinutes} min ${seconds} s`
: `${totalMinutes} min`;
}
const totalHours = Math.floor(totalMinutes / 60);
const hours = totalHours % 24;
if (totalHours < 24) {
return minutes > 0
? `${totalHours} h ${minutes} min`
: `${totalHours} h`;
}
const days = Math.floor(totalHours / 24);
return hours > 0
? `${days} d ${hours} h`
: `${days} d`;
}