213 lines
8.9 KiB
TypeScript
213 lines
8.9 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { getReactionLogs } from '../api'
|
|
import { formatRelativeTime } from '../utils'
|
|
import { Spinner, EmptyState, Pagination } from './Common'
|
|
|
|
function StatusBadge({ status }: { status: string }) {
|
|
const colors: Record<string, string> = {
|
|
success: 'bg-green-500/20 text-green-300 border-green-500/30',
|
|
failed: 'bg-red-500/20 text-red-300 border-red-500/30',
|
|
skipped: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
|
}
|
|
return (
|
|
<span
|
|
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${colors[status] || colors.skipped}`}
|
|
>
|
|
{status}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function truncate(value: any, maxLen = 60): string {
|
|
if (value === null || value === undefined) return '—'
|
|
const str = typeof value === 'string' ? value : JSON.stringify(value)
|
|
return str.length > maxLen ? str.slice(0, maxLen) + '...' : str
|
|
}
|
|
|
|
export default function ReactionLogs() {
|
|
const [data, setData] = useState<any[]>([])
|
|
const [error, setError] = useState('')
|
|
const [loading, setLoading] = useState(true)
|
|
const [total, setTotal] = useState(0)
|
|
const [limit, setLimit] = useState(50)
|
|
const [offset, setOffset] = useState(0)
|
|
const [filterReactionId, setFilterReactionId] = useState('')
|
|
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set())
|
|
|
|
const load = () => {
|
|
setLoading(true)
|
|
const rid = filterReactionId.trim() ? parseInt(filterReactionId, 10) : undefined
|
|
getReactionLogs(limit, offset, rid)
|
|
.then((res) => {
|
|
setData(res.reaction_logs)
|
|
setTotal(res.total)
|
|
})
|
|
.catch((e) => setError(e.message))
|
|
.finally(() => setLoading(false))
|
|
}
|
|
|
|
useEffect(() => {
|
|
load()
|
|
}, [limit, offset])
|
|
|
|
const toggleExpand = (id: number) => {
|
|
setExpandedRows((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(id)) next.delete(id)
|
|
else next.add(id)
|
|
return next
|
|
})
|
|
}
|
|
|
|
if (loading) return <Spinner />
|
|
if (error) return <div className="text-red-500 text-center p-8">Error: {error}</div>
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto">
|
|
<h2 className="text-2xl font-bold mb-6">Reaction Logs</h2>
|
|
|
|
{/* Filter */}
|
|
<div className="bg-gray-900/50 backdrop-blur rounded-lg border border-gray-800 p-4 mb-6">
|
|
<div className="flex items-end gap-3">
|
|
<div>
|
|
<label className="block text-xs text-gray-400 mb-1">Filter by Reaction ID</label>
|
|
<input
|
|
type="number"
|
|
value={filterReactionId}
|
|
onChange={(e) => setFilterReactionId(e.target.value)}
|
|
placeholder="Reaction ID"
|
|
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-40"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setOffset(0)
|
|
load()
|
|
}}
|
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm font-medium transition-colors"
|
|
>
|
|
Filter
|
|
</button>
|
|
{filterReactionId && (
|
|
<button
|
|
onClick={() => {
|
|
setFilterReactionId('')
|
|
setOffset(0)
|
|
setTimeout(load, 0)
|
|
}}
|
|
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm font-medium transition-colors"
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-gray-900/50 backdrop-blur rounded-lg overflow-hidden border border-gray-800">
|
|
{data.length === 0 ? (
|
|
<EmptyState message="No reaction logs found" />
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-800/80 border-b border-gray-700">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
ID
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
Reaction
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
Trigger Event
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
Projection
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
Old Value
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
New Value
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
Action
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
Status
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
Output
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
Duration
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
Created
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-800">
|
|
{data.map((log, i) => (
|
|
<tr
|
|
key={log.id}
|
|
className={`transition-colors ${
|
|
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-gray-850/30'
|
|
} hover:bg-gray-800/60`}
|
|
>
|
|
<td className="px-4 py-3 font-mono text-sm text-gray-300">{log.id}</td>
|
|
<td className="px-4 py-3 font-mono text-sm text-gray-300">{log.reaction_id}</td>
|
|
<td className="px-4 py-3 font-mono text-sm text-gray-300">{log.trigger_event_id}</td>
|
|
<td className="px-4 py-3 text-sm text-gray-400 max-w-[120px] truncate">
|
|
{truncate(log.projection_def, 30)}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-400 font-mono max-w-[120px] truncate">
|
|
{truncate(log.old_value)}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-400 font-mono max-w-[120px] truncate">
|
|
{truncate(log.new_value)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-500/20 text-blue-300 border border-blue-500/30">
|
|
{log.action}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<StatusBadge status={log.status} />
|
|
</td>
|
|
<td className="px-4 py-3 text-sm">
|
|
{log.handler_output ? (
|
|
<div>
|
|
<button
|
|
onClick={() => toggleExpand(log.id)}
|
|
className="text-blue-400 hover:text-blue-300 text-xs underline"
|
|
>
|
|
{expandedRows.has(log.id) ? 'Hide' : 'Show'}
|
|
</button>
|
|
{expandedRows.has(log.id) && (
|
|
<pre className="mt-2 p-2 bg-gray-800 rounded text-xs text-gray-300 max-w-xs overflow-auto whitespace-pre-wrap">
|
|
{typeof log.handler_output === 'string'
|
|
? log.handler_output
|
|
: JSON.stringify(log.handler_output, null, 2)}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<span className="text-gray-600">—</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-400 font-mono">
|
|
{log.duration_ms != null ? `${log.duration_ms}ms` : '—'}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-400">{formatRelativeTime(log.created_at)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
<Pagination total={total} limit={limit} offset={offset} onPageChange={setOffset} onLimitChange={setLimit} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|