Merge pull request #309 from Portabase/feat/backup-restore-logs

feat: backup-restore-logs
This commit is contained in:
Charles GTE
2026-06-07 17:41:43 +02:00
committed by GitHub
33 changed files with 14138 additions and 1389 deletions
+1
View File
@@ -59,3 +59,4 @@ certificates
.claude
.codex
/docs
.worktrees
@@ -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,13 +51,17 @@ export default async function RoutePage(props: PageParams<{
with: {
storageChannel: true
}
}
},
logs: true
},
orderBy: (b, {desc}) => [desc(b.createdAt)],
});
const restorations = await db.query.restoration.findMany({
where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
with: {
logs: true
},
orderBy: (r, {desc}) => [desc(r.createdAt)],
});
@@ -84,7 +89,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 +98,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>
);
}
@@ -85,7 +85,9 @@ export default async function RoutePage(props: PageParams<{
<PageContent className="flex flex-col w-full h-full">
{proj.databases.length > 0 ? (
<CardsWithPagination
data={proj.databases}
data={[...proj.databases].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)}
organizationSlug={organization.slug}
// @ts-ignore
cardItem={ProjectDatabaseCard}
+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);
+31 -5
View File
@@ -1,17 +1,20 @@
import {NextResponse} from "next/server";
import {isUuidv4} from "@/utils/verify-uuid";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {db as dbClient, db} from "@/db";
import {and, eq} from "drizzle-orm";
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers";
import {logger} from "@/lib/logger";
import {withUpdatedAt} from "@/db/utils";
import {JobLogEntry} from "@/features/logs/types";
const log = logger.child({module: "api/agent/restore"});
export type BodyResultRestore = {
generatedId: string
status: string
logs: JobLogEntry[]
durationMs: number
}
type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success';
@@ -60,11 +63,34 @@ export async function POST(
return NextResponse.json({error: "Unable to fin the corresponding restoration"}, {status: 404})
}
await db
const [restorationUpdated] = await db
.update(drizzleDb.schemas.restoration)
.set(withUpdatedAt({status: body.status as RestorationStatus}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
.set(withUpdatedAt({status: body.status as RestorationStatus, durationMs: body.durationMs}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)).returning();
const logsToInsert = body.logs.map((entry) => ({
backupId: null,
restorationId: restorationUpdated.id,
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);
}
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
+1 -1
View File
@@ -139,5 +139,5 @@
"typescript": "^5.9.3",
"zenstack": "2.14.2"
},
"packageManager": "pnpm@11.3.0"
"packageManager": "pnpm@11.5.2+sha512.71c631e382066efc25625d5cf029075de07b61b37f6e27350fbd84b1bda5864c8c1967adc280776b45c30a715c0359a3be08fef42d5bb09e2b99029979692916"
}
+1794 -1343
View File
File diff suppressed because it is too large Load Diff
+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;
@@ -0,0 +1 @@
ALTER TABLE "restorations" 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
File diff suppressed because it is too large Load Diff
+28
View File
@@ -407,6 +407,34 @@
"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
},
{
"idx": 61,
"version": "7",
"when": 1780822414802,
"tag": "0061_illegal_mole_man",
"breakpoints": true
}
]
}
+13 -2
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"}),
@@ -66,7 +68,7 @@ export const retentionPolicy = pgTable("retention_policies", {
export const restoration = pgTable("restorations", {
id: uuid("id").primaryKey().defaultRandom(),
status: statusEnum("status").default("waiting").notNull(),
durationMs: bigint("duration_ms", { mode: "number" }),
backupStorageId: uuid("backup_storage_id")
.references(() => backupStorage.id, {onDelete: "cascade"}),
backupId: uuid("backup_id")
@@ -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),
}));
@@ -137,6 +142,12 @@ export type DatabaseWith = Database & {
export type BackupWith = Backup & {
restorations?: Restoration[] | null;
storages?: BackupStorage[] | null;
logs?: JobLog[] | null;
};
export type RestorationWith = Restoration & {
logs?: JobLog[] | null;
};
+66
View File
@@ -0,0 +1,66 @@
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>;
+3 -1
View File
@@ -117,7 +117,9 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
<CardsWithPagination
cardsPerPage={4}
numberOfColumns={2}
data={agent.databases}
data={[...agent.databases].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)}
cardItem={AgentDatabaseCard}
/>
</div>
+1 -1
View File
@@ -2,7 +2,7 @@
import {action, ActionError, userAction} from "@/lib/safe-actions/actions";
import {AgentSchema} from "@/features/agents/agents.schema";
import {z} from "zod";
import {eq, and, ne, count} from "drizzle-orm";
import {eq, and, ne, count, desc} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {slugify} from "@/utils/slugify";
+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 {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}/>,
@@ -4,7 +4,7 @@ import {z} from "zod";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {BackupWith, Restoration} from "@/db/schema/07_database";
import {BackupWith, RestorationWith} from "@/db/schema/07_database";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
@@ -36,15 +36,19 @@ export const getDatabaseDataAction = userAction
with: {
storageChannel: true
}
}
},
logs: true
},
orderBy: (b, {desc}) => [desc(b.createdAt)],
}) as BackupWith[];
const restorations = await db.query.restoration.findMany({
where: eq(drizzleDb.schemas.restoration.databaseId, databaseId),
with: {
logs: true
},
orderBy: (r, {desc}) => [desc(r.createdAt)],
}) as Restoration[];
}) as RestorationWith[];
const totalBackups = backups.length;
const availableBackups = backups.filter(b => !b.deletedAt).length;
+4 -2
View File
@@ -2,7 +2,7 @@
import {DatabaseBackupActionsModal} from "@/features/database/backup-actions-modal";
import {DatabaseTabs} from "@/features/database/database-tabs";
import {Setting} from "@/db/schema/01_setting";
import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database";
import {BackupWith, DatabaseWith, RestorationWith} from "@/db/schema/07_database";
import {MemberWithUser} from "@/db/schema/03_organization";
import {useBackupModal} from "@/features/database/backup-modal-context";
import {DatabaseKpi} from "@/features/database/database-kpi";
@@ -23,11 +23,12 @@ 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;
backups: BackupWith[];
restorations: Restoration[];
restorations: RestorationWith[];
isAlreadyRestore: boolean;
database: DatabaseWith;
activeMember: MemberWithUser;
@@ -156,6 +157,7 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
availableBackups={stats.availableBackups}
totalBackups={stats.totalBackups}
/>
<LogsModal/>
<DatabaseBackupActionsModal/>
<DatabaseTabs
activeMember={props.activeMember}
+20 -2
View File
@@ -13,7 +13,7 @@ import { Button } from "@/components/ui/button";
import { Check, MoreHorizontal, Trash2, X } from "lucide-react";
import { ReloadIcon } from "@radix-ui/react-icons";
import { StatusBadge } from "@/components/common/status-badge";
import { Restoration } from "@/db/schema/07_database";
import {Restoration, RestorationWith} from "@/db/schema/07_database";
import { formatLocalizedDate } from "@/utils/date-formatting";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
@@ -24,11 +24,13 @@ import { toast } from "sonner";
import { TooltipCustom } from "@/components/common/tooltip-custom";
import { MemberWithUser } from "@/db/schema/03_organization";
import { ButtonWithConfirm } from "@/components/common/button-with-confirm";
import {LogsModalTrigger} from "@/features/logs/logs-modal-trigger";
import {formatDuration} from "@/utils/text";
export function restoreColumns(
isAlreadyRestore: boolean,
activeMember: MemberWithUser,
): ColumnDef<Restoration>[] {
): ColumnDef<RestorationWith>[] {
return [
{
accessorKey: "id",
@@ -48,6 +50,22 @@ export function restoreColumns(
return <StatusBadge status={row.getValue("status")} />;
},
},
{
accessorKey: "durationMs",
header: "Duration",
cell: ({row}) => {
const durationMs = row.getValue("durationMs");
return durationMs ? formatDuration(row.getValue("durationMs")) : "-"
},
},
{
accessorKey: "logs",
header: "Logs",
cell: ({row}) => {
const logs = row.original.logs ?? [];
return <LogsModalTrigger logs={logs}/>;
},
},
{
id: "actions",
cell: ({ row }) => {
+7 -2
View File
@@ -21,6 +21,9 @@ export const deleteRestoreAction = userAction
.where(and(eq(drizzleDb.schemas.restoration.id, parsedInput.restorationId)))
.execute();
await db
.delete(drizzleDb.schemas.jobLog)
.where(eq(drizzleDb.schemas.jobLog.restorationId, parsedInput.restorationId));
return {
success: true,
@@ -50,14 +53,16 @@ export const rerunRestorationAction = userAction
)
.action(async ({parsedInput}): Promise<ServerActionResult<Restoration>> => {
try {
const updateResult = await db
const [updatedRestoration] = await db
.update(drizzleDb.schemas.restoration)
.set({status: "waiting"})
.where(eq(drizzleDb.schemas.restoration.id, parsedInput.restorationId))
.returning()
.execute();
const updatedRestoration = updateResult[0];
await db
.delete(drizzleDb.schemas.jobLog)
.where(eq(drizzleDb.schemas.jobLog.restorationId, parsedInput.restorationId));
if (!updatedRestoration) {
return {
+131
View File
@@ -0,0 +1,131 @@
"use client"
import { useState } from "react"
import { Minus, Plus } from "lucide-react"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
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
? "border-transparent bg-secondary text-secondary-foreground"
: entry.level === "error"
? "border-transparent bg-destructive/20 text-destructive"
: entry.level === "warn"
? "border-transparent bg-amber-500/20 text-amber-600 dark:text-amber-300"
: entry.level === "debug"
? "border-transparent bg-muted text-muted-foreground"
: "border-transparent bg-sky-500/20 text-sky-600 dark:text-sky-300"
return (
<Badge variant="outline" className={cn("rounded-full", styles)}>
{label}
</Badge>
)
}
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} 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`;
}