mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
feat: backup and restore logs modal view
This commit is contained in:
+24
-20
@@ -9,6 +9,7 @@ import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
|||||||
import {BackupModalProvider} from "@/features/database/backup-modal-context";
|
import {BackupModalProvider} from "@/features/database/backup-modal-context";
|
||||||
import {DatabaseContent} from "@/features/database/database-content";
|
import {DatabaseContent} from "@/features/database/database-content";
|
||||||
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
|
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
|
||||||
|
import {LogsModalProvider} from "@/features/logs/logs-modal-context";
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{
|
export default async function RoutePage(props: PageParams<{
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -34,7 +35,7 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
project: true,
|
project: true,
|
||||||
retentionPolicy: true,
|
retentionPolicy: true,
|
||||||
alertPolicies: true,
|
alertPolicies: true,
|
||||||
storagePolicies: true
|
storagePolicies: true,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,7 +51,8 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
with: {
|
with: {
|
||||||
storageChannel: true
|
storageChannel: true
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
logs: true
|
||||||
},
|
},
|
||||||
orderBy: (b, {desc}) => [desc(b.createdAt)],
|
orderBy: (b, {desc}) => [desc(b.createdAt)],
|
||||||
});
|
});
|
||||||
@@ -84,7 +86,7 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
notFound();
|
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;
|
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
||||||
@@ -93,23 +95,25 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<BackupModalProvider>
|
<LogsModalProvider>
|
||||||
<DatabaseContent
|
<BackupModalProvider>
|
||||||
activeMember={activeMember}
|
<DatabaseContent
|
||||||
settings={settings}
|
activeMember={activeMember}
|
||||||
database={dbItem}
|
settings={settings}
|
||||||
databaseHealthLogs={databaseHealthLogs}
|
database={dbItem}
|
||||||
isAlreadyRestore={isAlreadyRestore}
|
databaseHealthLogs={databaseHealthLogs}
|
||||||
restorations={restorations}
|
isAlreadyRestore={isAlreadyRestore}
|
||||||
backups={backups}
|
restorations={restorations}
|
||||||
totalBackups={totalBackups}
|
backups={backups}
|
||||||
availableBackups={availableBackups}
|
totalBackups={totalBackups}
|
||||||
successRate={successRate}
|
availableBackups={availableBackups}
|
||||||
organizationId={organization.id}
|
successRate={successRate}
|
||||||
activeOrganizationChannels={[]}
|
organizationId={organization.id}
|
||||||
activeOrganizationStorageChannels={[]}
|
activeOrganizationChannels={[]}
|
||||||
/>
|
activeOrganizationStorageChannels={[]}
|
||||||
</BackupModalProvider>
|
/>
|
||||||
|
</BackupModalProvider>
|
||||||
|
</LogsModalProvider>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ import {eventEmitter} from "@/lib/event";
|
|||||||
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers";
|
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers";
|
||||||
import {EventKind} from "@/features/notifications/notifications.types";
|
import {EventKind} from "@/features/notifications/notifications.types";
|
||||||
import {logger} from "@/lib/logger";
|
import {logger} from "@/lib/logger";
|
||||||
|
import {JobLogEntry} from "@/features/logs/types";
|
||||||
|
|
||||||
const log = logger.child({module: "api/agent/backup/route"});
|
const log = logger.child({module: "api/agent/backup/route"});
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@ export type BodyPatch = {
|
|||||||
status: "success" | "failed"
|
status: "success" | "failed"
|
||||||
size: number
|
size: number
|
||||||
generatedId: string
|
generatedId: string
|
||||||
|
logs: JobLogEntry[]
|
||||||
|
durationMs: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||||
@@ -30,6 +33,7 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
|||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
const body: BodyPost = await request.json();
|
const body: BodyPost = await request.json();
|
||||||
|
|
||||||
const method = body.method
|
const method = body.method
|
||||||
const database = await getDatabaseOrThrow(body.generatedId);
|
const database = await getDatabaseOrThrow(body.generatedId);
|
||||||
|
|
||||||
@@ -127,12 +131,37 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
|||||||
.update(drizzleDb.schemas.backup)
|
.update(drizzleDb.schemas.backup)
|
||||||
.set(withUpdatedAt({
|
.set(withUpdatedAt({
|
||||||
status: status,
|
status: status,
|
||||||
fileSize: backupSize
|
fileSize: backupSize,
|
||||||
|
durationMs: body.durationMs
|
||||||
}))
|
}))
|
||||||
.where(eq(drizzleDb.schemas.backup.id, backup.id))
|
.where(eq(drizzleDb.schemas.backup.id, backup.id))
|
||||||
.returning();
|
.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});
|
eventEmitter.emit('modification', {update: true});
|
||||||
await sendNotificationsBackupRestore(database, status == "failed" ? "error_backup" : "success_backup" as EventKind);
|
await sendNotificationsBackupRestore(database, status == "failed" ? "error_backup" : "success_backup" as EventKind);
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -17,6 +17,7 @@ import * as storagePolicy from "@/db/schema/13_storage-policy";
|
|||||||
import * as backupStorage from "@/db/schema/14_storage-backup";
|
import * as backupStorage from "@/db/schema/14_storage-backup";
|
||||||
import * as healthcheckLog from "@/db/schema/15_healthcheck-log";
|
import * as healthcheckLog from "@/db/schema/15_healthcheck-log";
|
||||||
import * as apiKey from "@/db/schema/16_apikey";
|
import * as apiKey from "@/db/schema/16_apikey";
|
||||||
|
import * as jobLog from "@/db/schema/17_job-log";
|
||||||
|
|
||||||
const log = logger.child({module: "db"});
|
const log = logger.child({module: "db"});
|
||||||
|
|
||||||
@@ -53,7 +54,8 @@ export const schemas = {
|
|||||||
...storagePolicy,
|
...storagePolicy,
|
||||||
...backupStorage,
|
...backupStorage,
|
||||||
...healthcheckLog,
|
...healthcheckLog,
|
||||||
...apiKey
|
...apiKey,
|
||||||
|
...jobLog
|
||||||
};
|
};
|
||||||
|
|
||||||
export const db = drizzle({
|
export const db = drizzle({
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -407,6 +407,27 @@
|
|||||||
"when": 1779698258492,
|
"when": 1779698258492,
|
||||||
"tag": "0057_cooing_nocturne",
|
"tag": "0057_cooing_nocturne",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ import {timestamps} from "@/db/schema/00_common";
|
|||||||
import {AlertPolicy, alertPolicy} from "@/db/schema/10_alert-policy";
|
import {AlertPolicy, alertPolicy} from "@/db/schema/10_alert-policy";
|
||||||
import {StoragePolicy, storagePolicy} from "@/db/schema/13_storage-policy";
|
import {StoragePolicy, storagePolicy} from "@/db/schema/13_storage-policy";
|
||||||
import {BackupStorage, backupStorage} from "@/db/schema/14_storage-backup";
|
import {BackupStorage, backupStorage} from "@/db/schema/14_storage-backup";
|
||||||
|
import {JobLog, jobLog} from "@/db/schema/17_job-log";
|
||||||
|
|
||||||
export const database = pgTable("databases", {
|
export const database = pgTable("databases", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
@@ -38,6 +39,7 @@ export const backup = pgTable(
|
|||||||
status: statusEnum("status").default("waiting").notNull(),
|
status: statusEnum("status").default("waiting").notNull(),
|
||||||
file: text("file"),
|
file: text("file"),
|
||||||
fileSize: bigint("file_size", { mode: "number" }),
|
fileSize: bigint("file_size", { mode: "number" }),
|
||||||
|
durationMs: bigint("duration_ms", { mode: "number" }),
|
||||||
databaseId: uuid("database_id")
|
databaseId: uuid("database_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => database.id, {onDelete: "cascade"}),
|
.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]}),
|
database: one(database, {fields: [backup.databaseId], references: [database.id]}),
|
||||||
restorations: many(restoration),
|
restorations: many(restoration),
|
||||||
storages: many(backupStorage),
|
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]}),
|
backup: one(backup, {fields: [restoration.backupId], references: [backup.id]}),
|
||||||
database: one(database, {fields: [restoration.databaseId], references: [database.id]}),
|
database: one(database, {fields: [restoration.databaseId], references: [database.id]}),
|
||||||
backupStorage: one(backupStorage, {fields: [restoration.backupStorageId], references: [backupStorage.id]}),
|
backupStorage: one(backupStorage, {fields: [restoration.backupStorageId], references: [backupStorage.id]}),
|
||||||
|
logs: many(jobLog),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
@@ -131,12 +136,16 @@ export type DatabaseWith = Database & {
|
|||||||
retentionPolicy?: RetentionPolicy | null;
|
retentionPolicy?: RetentionPolicy | null;
|
||||||
alertPolicies?: AlertPolicy[] | null;
|
alertPolicies?: AlertPolicy[] | null;
|
||||||
storagePolicies?: StoragePolicy[] | null;
|
storagePolicies?: StoragePolicy[] | null;
|
||||||
|
logs?: JobLog[] | null;
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export type BackupWith = Backup & {
|
export type BackupWith = Backup & {
|
||||||
restorations?: Restoration[] | null;
|
restorations?: Restoration[] | null;
|
||||||
storages?: BackupStorage[] | null;
|
storages?: BackupStorage[] | null;
|
||||||
|
logs?: JobLog[] | null;
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|
||||||
|
|
||||||
@@ -2,23 +2,24 @@
|
|||||||
|
|
||||||
import {ColumnDef} from "@tanstack/react-table";
|
import {ColumnDef} from "@tanstack/react-table";
|
||||||
import {StatusBadge} from "@/components/common/status-badge";
|
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 {Setting} from "@/db/schema/01_setting";
|
||||||
import {cn} from "@/lib/utils";
|
import {cn} from "@/lib/utils";
|
||||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||||
import {MemberWithUser} from "@/db/schema/03_organization";
|
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||||
import {formatLocalizedDate} from "@/utils/date-formatting";
|
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 {DatabaseActionsCell} from "@/features/database/backup-actions-cell";
|
||||||
import { Badge as BadgeC } from "@/components/ui/badge";
|
import { Badge as BadgeC } from "@/components/ui/badge";
|
||||||
import {backupOnly} from "@/features/database/database-tabs";
|
import {backupOnly} from "@/features/database/database-tabs";
|
||||||
|
import {LogsModalTrigger} from "@/features/logs/logs-modal-trigger";
|
||||||
|
|
||||||
export function backupColumns(
|
export function backupColumns(
|
||||||
isAlreadyRestore: boolean,
|
isAlreadyRestore: boolean,
|
||||||
settings: Setting,
|
settings: Setting,
|
||||||
database: DatabaseWith,
|
database: DatabaseWith,
|
||||||
activeMember: MemberWithUser
|
activeMember: MemberWithUser
|
||||||
): ColumnDef<Backup>[] {
|
): ColumnDef<BackupWith>[] {
|
||||||
|
|
||||||
const isBackupOnly = backupOnly.some((type) => database.dbms === type)
|
const isBackupOnly = backupOnly.some((type) => database.dbms === type)
|
||||||
|
|
||||||
@@ -85,6 +86,14 @@ export function backupColumns(
|
|||||||
return formatBytes(row.getValue("fileSize"))
|
return formatBytes(row.getValue("fileSize"))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "durationMs",
|
||||||
|
header: "Duration",
|
||||||
|
cell: ({row}) => {
|
||||||
|
const durationMs = row.getValue("durationMs");
|
||||||
|
return durationMs ? formatDuration(row.getValue("durationMs")) : "-"
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "createdAt",
|
accessorKey: "createdAt",
|
||||||
header: "Created At",
|
header: "Created At",
|
||||||
@@ -99,6 +108,14 @@ export function backupColumns(
|
|||||||
return <StatusBadge status={row.getValue("status")}/>;
|
return <StatusBadge status={row.getValue("status")}/>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "logs",
|
||||||
|
header: "Logs",
|
||||||
|
cell: ({row}) => {
|
||||||
|
const logs = row.original.logs ?? [];
|
||||||
|
return <LogsModalTrigger logs={logs}/>;
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({row}) => <DatabaseActionsCell isAlreadyRestore={isAlreadyRestore} activeMember={activeMember} backup={row.original} isBackupOnly={isBackupOnly}/>,
|
cell: ({row}) => <DatabaseActionsCell isAlreadyRestore={isAlreadyRestore} activeMember={activeMember} backup={row.original} isBackupOnly={isBackupOnly}/>,
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ export const getDatabaseDataAction = userAction
|
|||||||
with: {
|
with: {
|
||||||
storageChannel: true
|
storageChannel: true
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
logs: true
|
||||||
},
|
},
|
||||||
orderBy: (b, {desc}) => [desc(b.createdAt)],
|
orderBy: (b, {desc}) => [desc(b.createdAt)],
|
||||||
}) as BackupWith[];
|
}) as BackupWith[];
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {BackupButton} from "@/features/database/backup-button";
|
|||||||
import {HealthModal} from "@/features/database/health-modal";
|
import {HealthModal} from "@/features/database/health-modal";
|
||||||
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
|
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
|
||||||
import {Badge} from "@/components/ui/badge";
|
import {Badge} from "@/components/ui/badge";
|
||||||
|
import {LogsModal} from "@/features/logs/logs-modal";
|
||||||
|
|
||||||
export type DatabaseContentProps = {
|
export type DatabaseContentProps = {
|
||||||
settings: Setting;
|
settings: Setting;
|
||||||
@@ -156,6 +157,7 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
|
|||||||
availableBackups={stats.availableBackups}
|
availableBackups={stats.availableBackups}
|
||||||
totalBackups={stats.totalBackups}
|
totalBackups={stats.totalBackups}
|
||||||
/>
|
/>
|
||||||
|
<LogsModal/>
|
||||||
<DatabaseBackupActionsModal/>
|
<DatabaseBackupActionsModal/>
|
||||||
<DatabaseTabs
|
<DatabaseTabs
|
||||||
activeMember={props.activeMember}
|
activeMember={props.activeMember}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,5 +1,4 @@
|
|||||||
import {formatDistanceToNow} from "date-fns";
|
import {formatDistanceToNow} from "date-fns";
|
||||||
import {format} from "date-fns";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get user's locale and timezone from the browser
|
* Get user's locale and timezone from the browser
|
||||||
|
|||||||
@@ -25,4 +25,45 @@ export function formatBytes(bytes: number | null, decimals = 2): string {
|
|||||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
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`;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user