feat(audit): add /audit list and /audit/[flow_run_id] detail pages
This commit is contained in:
parent
f99b12ad5c
commit
2ed378fb8f
2 changed files with 225 additions and 0 deletions
125
app/audit/[flow_run_id]/page.tsx
Normal file
125
app/audit/[flow_run_id]/page.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { notFound, redirect } from 'next/navigation'
|
||||||
|
import { getCurrentUser } from '@/lib/session'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ flow_run_id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<string, string> = {
|
||||||
|
pending: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400',
|
||||||
|
running: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||||||
|
success: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
failed: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
cancelled: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AuditDetailPage({ params }: Props) {
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) redirect('/login')
|
||||||
|
|
||||||
|
const { flow_run_id } = await params
|
||||||
|
|
||||||
|
const run = await prisma.flowRun.findUnique({
|
||||||
|
where: { id: flow_run_id },
|
||||||
|
include: { steps: { orderBy: { step_index: 'asc' } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!run || run.user_id !== user.id) notFound()
|
||||||
|
|
||||||
|
const durationMs =
|
||||||
|
run.ended_at && run.started_at
|
||||||
|
? run.ended_at.getTime() - run.started_at.getTime()
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background p-6">
|
||||||
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/audit" className="text-sm text-muted-foreground hover:text-foreground">
|
||||||
|
← Audit Log
|
||||||
|
</Link>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight font-mono">{run.flow_key}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-border p-4 space-y-3 text-sm">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Status</div>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ' +
|
||||||
|
(STATUS_STYLES[run.status] ?? '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{run.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Exit code</div>
|
||||||
|
<span className="font-mono text-xs">{run.exit_code != null ? run.exit_code : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Started</div>
|
||||||
|
<span className="text-xs">{run.started_at.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Duration</div>
|
||||||
|
<span className="text-xs">{durationMs != null ? `${(durationMs / 1000).toFixed(1)}s` : '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{run.steps.map((step) => {
|
||||||
|
const args = step.args_json ? (JSON.parse(step.args_json) as string[]) : []
|
||||||
|
return (
|
||||||
|
<div key={step.id} className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">Step {step.step_index + 1}</span>
|
||||||
|
<span className="font-mono text-sm font-medium">{step.command_key}</span>
|
||||||
|
{args.length > 0 && (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{args.join(' ')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(step.stdout || step.stderr) && (
|
||||||
|
<div className="rounded-lg border border-border bg-zinc-950 font-mono text-xs overflow-hidden">
|
||||||
|
<div className="max-h-96 overflow-y-auto p-3 space-y-0">
|
||||||
|
{step.stdout && (
|
||||||
|
<pre className="whitespace-pre-wrap break-all leading-5 text-zinc-100">
|
||||||
|
{step.stdout}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{step.stderr && (
|
||||||
|
<pre className="whitespace-pre-wrap break-all leading-5 text-red-400">
|
||||||
|
{step.stderr}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border px-3 py-1.5 text-xs text-muted-foreground">
|
||||||
|
exit {step.exit_code != null ? step.exit_code : '—'}
|
||||||
|
{step.ended_at && step.started_at && (
|
||||||
|
<span className="ml-3">
|
||||||
|
{((step.ended_at.getTime() - step.started_at.getTime()) / 1000).toFixed(1)}s
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!step.stdout && !step.stderr && (
|
||||||
|
<div className="rounded-lg border border-border p-4 text-xs text-muted-foreground">
|
||||||
|
No output recorded.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
100
app/audit/page.tsx
Normal file
100
app/audit/page.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { getCurrentUser } from '@/lib/session'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<string, string> = {
|
||||||
|
pending: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400',
|
||||||
|
running: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||||||
|
success: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
failed: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
cancelled: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AuditPage() {
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) redirect('/login')
|
||||||
|
|
||||||
|
const runs = await prisma.flowRun.findMany({
|
||||||
|
where: { user_id: user.id },
|
||||||
|
orderBy: { started_at: 'desc' },
|
||||||
|
take: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background p-6">
|
||||||
|
<div className="mx-auto max-w-6xl space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Audit Log</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Recent write actions executed on this server</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{runs.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-border p-8 text-center text-sm text-muted-foreground">
|
||||||
|
No actions have been run yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-muted/50">
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Command</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Exit</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Started</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{runs.map((run) => {
|
||||||
|
const durationMs =
|
||||||
|
run.ended_at && run.started_at
|
||||||
|
? run.ended_at.getTime() - run.started_at.getTime()
|
||||||
|
: null
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={run.id}
|
||||||
|
className="border-b border-border last:border-0 hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">
|
||||||
|
<Link
|
||||||
|
href={`/audit/${run.id}`}
|
||||||
|
className="font-medium text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{run.flow_key}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ' +
|
||||||
|
(STATUS_STYLES[run.status] ?? '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{run.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
|
||||||
|
{run.exit_code != null ? run.exit_code : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-muted-foreground">
|
||||||
|
{run.started_at.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-muted-foreground">
|
||||||
|
{durationMs != null ? `${(durationMs / 1000).toFixed(1)}s` : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue