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:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user