change capture

This commit is contained in:
2026-02-26 14:34:02 -06:00
parent 7ee3604eb1
commit b43495b26d
47 changed files with 5785 additions and 1525 deletions

View File

@@ -0,0 +1,772 @@
import { useMemo, useState } from "react";
import {
Activity,
AlertTriangle,
BarChart3,
CheckCircle,
Server,
Zap,
} from "lucide-react";
import type {
DashboardAnalytics,
TimeSeriesPoint,
FailureRateSummary,
} from "@/hooks/useAnalytics";
// ---------------------------------------------------------------------------
// Shared types & helpers
// ---------------------------------------------------------------------------
type TimeRangeHours = 6 | 12 | 24 | 48 | 168;
const TIME_RANGE_OPTIONS: { label: string; value: TimeRangeHours }[] = [
{ label: "6h", value: 6 },
{ label: "12h", value: 12 },
{ label: "24h", value: 24 },
{ label: "2d", value: 48 },
{ label: "7d", value: 168 },
];
function formatBucketLabel(iso: string, rangeHours: number): string {
const d = new Date(iso);
if (rangeHours <= 24) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
if (rangeHours <= 48) {
return d.toLocaleDateString([], { weekday: "short", hour: "2-digit" });
}
return d.toLocaleDateString([], { month: "short", day: "numeric" });
}
function formatBucketTooltip(iso: string): string {
const d = new Date(iso);
return d.toLocaleString();
}
/**
* Aggregate TimeSeriesPoints into per-bucket totals or per-bucket-per-label groups.
*/
function aggregateByBucket(
points: TimeSeriesPoint[],
): Map<string, { total: number; byLabel: Map<string, number> }> {
const map = new Map<
string,
{ total: number; byLabel: Map<string, number> }
>();
for (const p of points) {
let entry = map.get(p.bucket);
if (!entry) {
entry = { total: 0, byLabel: new Map() };
map.set(p.bucket, entry);
}
entry.total += p.value;
if (p.label) {
entry.byLabel.set(p.label, (entry.byLabel.get(p.label) || 0) + p.value);
}
}
return map;
}
// ---------------------------------------------------------------------------
// TimeRangeSelector
// ---------------------------------------------------------------------------
interface TimeRangeSelectorProps {
value: TimeRangeHours;
onChange: (v: TimeRangeHours) => void;
}
function TimeRangeSelector({ value, onChange }: TimeRangeSelectorProps) {
return (
<div className="inline-flex items-center bg-gray-100 rounded-md p-0.5 text-xs">
{TIME_RANGE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => onChange(opt.value)}
className={`px-2 py-1 rounded transition-colors ${
value === opt.value
? "bg-white shadow text-gray-900 font-medium"
: "text-gray-500 hover:text-gray-700"
}`}
>
{opt.label}
</button>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// MiniBarChart — pure-CSS bar chart for time-series data
// ---------------------------------------------------------------------------
interface MiniBarChartProps {
/** Ordered time buckets with totals */
buckets: { bucket: string; value: number }[];
/** Current time range in hours (affects label formatting) */
rangeHours: number;
/** Bar color class (Tailwind bg-* class) */
barColor?: string;
/** Height of the chart in pixels */
height?: number;
/** Show zero line */
showZeroLine?: boolean;
}
function MiniBarChart({
buckets,
rangeHours,
barColor = "bg-blue-500",
height = 120,
showZeroLine = true,
}: MiniBarChartProps) {
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
const maxValue = useMemo(
() => Math.max(1, ...buckets.map((b) => b.value)),
[buckets],
);
if (buckets.length === 0) {
return (
<div
className="flex items-center justify-center text-gray-400 text-xs"
style={{ height }}
>
No data in this time range
</div>
);
}
// For large ranges, show fewer labels to avoid clutter
const labelEvery =
buckets.length > 24
? Math.ceil(buckets.length / 8)
: buckets.length > 12
? 2
: 1;
return (
<div className="relative" style={{ height: height + 24 }}>
{/* Tooltip */}
{hoveredIdx !== null && buckets[hoveredIdx] && (
<div className="absolute -top-1 left-1/2 -translate-x-1/2 z-10 bg-gray-800 text-white text-xs rounded px-2 py-1 whitespace-nowrap pointer-events-none shadow-lg">
{formatBucketTooltip(buckets[hoveredIdx].bucket)}:{" "}
<span className="font-semibold">{buckets[hoveredIdx].value}</span>
</div>
)}
{/* Bars */}
<div className="flex items-end gap-px w-full" style={{ height }}>
{buckets.map((b, i) => {
const pct = (b.value / maxValue) * 100;
return (
<div
key={b.bucket}
className="flex-1 min-w-0 relative group"
style={{ height: "100%" }}
onMouseEnter={() => setHoveredIdx(i)}
onMouseLeave={() => setHoveredIdx(null)}
>
<div className="absolute bottom-0 inset-x-0 flex justify-center">
<div
className={`w-full rounded-t-sm transition-all duration-150 ${
hoveredIdx === i ? barColor.replace("500", "600") : barColor
} ${hoveredIdx === i ? "opacity-100" : "opacity-80"}`}
style={{
height: `${Math.max(pct, b.value > 0 ? 2 : 0)}%`,
minHeight: b.value > 0 ? "2px" : "0",
}}
/>
</div>
</div>
);
})}
</div>
{/* Zero line */}
{showZeroLine && (
<div className="absolute bottom-6 left-0 right-0 border-t border-gray-200" />
)}
{/* X-axis labels */}
<div className="flex items-start mt-1 h-5">
{buckets.map((b, i) =>
i % labelEvery === 0 ? (
<div
key={b.bucket}
className="flex-1 text-center text-[9px] text-gray-400 truncate"
style={{ minWidth: 0 }}
>
{formatBucketLabel(b.bucket, rangeHours)}
</div>
) : (
<div key={b.bucket} className="flex-1" />
),
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// StackedBarChart — stacked bar chart for status breakdowns
// ---------------------------------------------------------------------------
const STATUS_COLORS: Record<string, { bg: string; legend: string }> = {
completed: { bg: "bg-green-500", legend: "bg-green-500" },
failed: { bg: "bg-red-500", legend: "bg-red-500" },
timeout: { bg: "bg-orange-500", legend: "bg-orange-500" },
running: { bg: "bg-blue-500", legend: "bg-blue-500" },
requested: { bg: "bg-yellow-400", legend: "bg-yellow-400" },
scheduled: { bg: "bg-yellow-500", legend: "bg-yellow-500" },
scheduling: { bg: "bg-yellow-300", legend: "bg-yellow-300" },
cancelled: { bg: "bg-gray-400", legend: "bg-gray-400" },
canceling: { bg: "bg-gray-300", legend: "bg-gray-300" },
abandoned: { bg: "bg-purple-400", legend: "bg-purple-400" },
online: { bg: "bg-green-500", legend: "bg-green-500" },
offline: { bg: "bg-red-400", legend: "bg-red-400" },
draining: { bg: "bg-yellow-500", legend: "bg-yellow-500" },
};
function getStatusColor(status: string): string {
return STATUS_COLORS[status]?.bg || "bg-gray-400";
}
interface StackedBarChartProps {
points: TimeSeriesPoint[];
rangeHours: number;
height?: number;
}
function StackedBarChart({
points,
rangeHours,
height = 120,
}: StackedBarChartProps) {
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
const { buckets, allLabels, maxTotal } = useMemo(() => {
const agg = aggregateByBucket(points);
const sorted = Array.from(agg.entries()).sort(([a], [b]) =>
a.localeCompare(b),
);
const labels = new Set<string>();
sorted.forEach(([, v]) => v.byLabel.forEach((_, k) => labels.add(k)));
const mx = Math.max(1, ...sorted.map(([, v]) => v.total));
return {
buckets: sorted.map(([bucket, v]) => ({
bucket,
total: v.total,
byLabel: v.byLabel,
})),
allLabels: Array.from(labels).sort(),
maxTotal: mx,
};
}, [points]);
if (buckets.length === 0) {
return (
<div
className="flex items-center justify-center text-gray-400 text-xs"
style={{ height }}
>
No data in this time range
</div>
);
}
const labelEvery =
buckets.length > 24
? Math.ceil(buckets.length / 8)
: buckets.length > 12
? 2
: 1;
return (
<div>
{/* Legend */}
<div className="flex flex-wrap gap-x-3 gap-y-1 mb-2">
{allLabels.map((label) => (
<div
key={label}
className="flex items-center gap-1 text-[10px] text-gray-600"
>
<div
className={`w-2 h-2 rounded-sm ${STATUS_COLORS[label]?.legend || "bg-gray-400"}`}
/>
{label}
</div>
))}
</div>
<div className="relative" style={{ height: height + 24 }}>
{/* Tooltip */}
{hoveredIdx !== null && buckets[hoveredIdx] && (
<div className="absolute -top-1 left-1/2 -translate-x-1/2 z-10 bg-gray-800 text-white text-xs rounded px-2 py-1 whitespace-nowrap pointer-events-none shadow-lg">
<div className="font-medium mb-0.5">
{formatBucketTooltip(buckets[hoveredIdx].bucket)}
</div>
{Array.from(buckets[hoveredIdx].byLabel.entries()).map(
([label, count]) => (
<div key={label}>
{label}: {count}
</div>
),
)}
</div>
)}
{/* Bars */}
<div className="flex items-end gap-px w-full" style={{ height }}>
{buckets.map((b, i) => {
const totalPct = (b.total / maxTotal) * 100;
return (
<div
key={b.bucket}
className="flex-1 min-w-0 relative"
style={{ height: "100%" }}
onMouseEnter={() => setHoveredIdx(i)}
onMouseLeave={() => setHoveredIdx(null)}
>
<div
className="absolute bottom-0 inset-x-0 flex flex-col-reverse"
style={{
height: `${Math.max(totalPct, b.total > 0 ? 2 : 0)}%`,
minHeight: b.total > 0 ? "2px" : "0",
}}
>
{allLabels.map((label) => {
const count = b.byLabel.get(label) || 0;
if (count === 0) return null;
const segmentPct = (count / b.total) * 100;
return (
<div
key={label}
className={`w-full ${getStatusColor(label)} ${
hoveredIdx === i ? "opacity-100" : "opacity-80"
} transition-opacity`}
style={{
height: `${segmentPct}%`,
minHeight: "1px",
}}
/>
);
})}
</div>
</div>
);
})}
</div>
{/* X-axis labels */}
<div className="flex items-start mt-1 h-5">
{buckets.map((b, i) =>
i % labelEvery === 0 ? (
<div
key={b.bucket}
className="flex-1 text-center text-[9px] text-gray-400 truncate"
style={{ minWidth: 0 }}
>
{formatBucketLabel(b.bucket, rangeHours)}
</div>
) : (
<div key={b.bucket} className="flex-1" />
),
)}
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// FailureRateCard
// ---------------------------------------------------------------------------
interface FailureRateCardProps {
summary: FailureRateSummary;
}
function FailureRateCard({ summary }: FailureRateCardProps) {
const rate = summary.failure_rate_pct;
const rateColor =
rate === 0
? "text-green-600"
: rate < 5
? "text-yellow-600"
: rate < 20
? "text-orange-600"
: "text-red-600";
const ringColor =
rate === 0
? "stroke-green-500"
: rate < 5
? "stroke-yellow-500"
: rate < 20
? "stroke-orange-500"
: "stroke-red-500";
// SVG ring gauge
const radius = 40;
const circumference = 2 * Math.PI * radius;
const failureArc = (rate / 100) * circumference;
const successArc = circumference - failureArc;
return (
<div className="flex items-center gap-6">
{/* Ring gauge */}
<div className="relative flex-shrink-0">
<svg width="100" height="100" className="-rotate-90">
{/* Background ring */}
<circle
cx="50"
cy="50"
r={radius}
fill="none"
strokeWidth="8"
className="stroke-gray-200"
/>
{/* Success arc */}
<circle
cx="50"
cy="50"
r={radius}
fill="none"
strokeWidth="8"
className="stroke-green-400"
strokeDasharray={`${successArc} ${circumference}`}
strokeLinecap="round"
/>
{/* Failure arc */}
{rate > 0 && (
<circle
cx="50"
cy="50"
r={radius}
fill="none"
strokeWidth="8"
className={ringColor}
strokeDasharray={`${failureArc} ${circumference}`}
strokeDashoffset={`${-successArc}`}
strokeLinecap="round"
/>
)}
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className={`text-lg font-bold ${rateColor}`}>
{rate.toFixed(1)}%
</span>
</div>
</div>
{/* Breakdown */}
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500" />
<span className="text-gray-600">Completed:</span>
<span className="font-medium text-gray-900">
{summary.completed_count}
</span>
</div>
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-red-500" />
<span className="text-gray-600">Failed:</span>
<span className="font-medium text-gray-900">
{summary.failed_count}
</span>
</div>
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-orange-500" />
<span className="text-gray-600">Timeout:</span>
<span className="font-medium text-gray-900">
{summary.timeout_count}
</span>
</div>
<div className="text-xs text-gray-400 mt-1">
{summary.total_terminal} total terminal executions
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// StatCard — simple metric card with icon and value
// ---------------------------------------------------------------------------
interface StatCardProps {
icon: React.ReactNode;
label: string;
value: number | string;
subtext?: string;
color?: string;
}
function StatCard({
icon,
label,
value,
subtext,
color = "text-blue-600",
}: StatCardProps) {
return (
<div className="flex items-center gap-3">
<div className={`${color} opacity-70`}>{icon}</div>
<div>
<p className="text-xs text-gray-500">{label}</p>
<p className={`text-2xl font-bold ${color}`}>{value}</p>
{subtext && <p className="text-[10px] text-gray-400">{subtext}</p>}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// AnalyticsDashboard — main composite widget
// ---------------------------------------------------------------------------
interface AnalyticsDashboardProps {
/** The analytics data (from useDashboardAnalytics hook) */
data: DashboardAnalytics | undefined;
/** Whether the data is loading */
isLoading: boolean;
/** Error object if the fetch failed */
error: Error | null;
/** Current time range in hours */
hours: TimeRangeHours;
/** Callback to change the time range */
onHoursChange: (h: TimeRangeHours) => void;
}
export default function AnalyticsDashboard({
data,
isLoading,
error,
hours,
onHoursChange,
}: AnalyticsDashboardProps) {
const executionBuckets = useMemo(() => {
if (!data?.execution_throughput) return [];
const agg = aggregateByBucket(data.execution_throughput);
return Array.from(agg.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([bucket, v]) => ({ bucket, value: v.total }));
}, [data?.execution_throughput]);
const eventBuckets = useMemo(() => {
if (!data?.event_volume) return [];
const agg = aggregateByBucket(data.event_volume);
return Array.from(agg.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([bucket, v]) => ({ bucket, value: v.total }));
}, [data?.event_volume]);
const enforcementBuckets = useMemo(() => {
if (!data?.enforcement_volume) return [];
const agg = aggregateByBucket(data.enforcement_volume);
return Array.from(agg.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([bucket, v]) => ({ bucket, value: v.total }));
}, [data?.enforcement_volume]);
const totalExecutions = useMemo(
() => executionBuckets.reduce((s, b) => s + b.value, 0),
[executionBuckets],
);
const totalEvents = useMemo(
() => eventBuckets.reduce((s, b) => s + b.value, 0),
[eventBuckets],
);
const totalEnforcements = useMemo(
() => enforcementBuckets.reduce((s, b) => s + b.value, 0),
[enforcementBuckets],
);
// Loading state
if (isLoading && !data) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900">Analytics</h2>
</div>
<TimeRangeSelector value={hours} onChange={onHoursChange} />
</div>
<div className="flex items-center justify-center py-12">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
</div>
);
}
// Error state
if (error) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900">Analytics</h2>
</div>
<TimeRangeSelector value={hours} onChange={onHoursChange} />
</div>
<div className="bg-red-50 border border-red-200 text-red-700 rounded p-3 text-sm">
Failed to load analytics data.{" "}
{error.message && (
<span className="text-red-500">{error.message}</span>
)}
</div>
</div>
);
}
if (!data) return null;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900">Analytics</h2>
{isLoading && (
<div className="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-blue-400" />
)}
</div>
<TimeRangeSelector value={hours} onChange={onHoursChange} />
</div>
{/* Summary stat cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="bg-white rounded-lg shadow p-4">
<StatCard
icon={<Activity className="h-5 w-5" />}
label={`Executions (${hours}h)`}
value={totalExecutions}
color="text-blue-600"
/>
</div>
<div className="bg-white rounded-lg shadow p-4">
<StatCard
icon={<Zap className="h-5 w-5" />}
label={`Events (${hours}h)`}
value={totalEvents}
color="text-indigo-600"
/>
</div>
<div className="bg-white rounded-lg shadow p-4">
<StatCard
icon={<CheckCircle className="h-5 w-5" />}
label={`Enforcements (${hours}h)`}
value={totalEnforcements}
color="text-purple-600"
/>
</div>
</div>
{/* Charts row 1: throughput + failure rate */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Execution throughput */}
<div className="bg-white rounded-lg shadow p-6 lg:col-span-2">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1.5">
<Activity className="h-4 w-4 text-blue-500" />
Execution Throughput
</h3>
<MiniBarChart
buckets={executionBuckets}
rangeHours={hours}
barColor="bg-blue-500"
height={140}
/>
</div>
{/* Failure rate */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1.5">
<AlertTriangle className="h-4 w-4 text-red-500" />
Failure Rate
</h3>
<FailureRateCard summary={data.failure_rate} />
</div>
</div>
{/* Charts row 2: status breakdown + event volume */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Execution status breakdown */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1.5">
<BarChart3 className="h-4 w-4 text-green-500" />
Execution Status Over Time
</h3>
<StackedBarChart
points={data.execution_status}
rangeHours={hours}
height={140}
/>
</div>
{/* Event volume */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1.5">
<Zap className="h-4 w-4 text-indigo-500" />
Event Volume
</h3>
<MiniBarChart
buckets={eventBuckets}
rangeHours={hours}
barColor="bg-indigo-500"
height={140}
/>
</div>
</div>
{/* Charts row 3: enforcements + worker health */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Enforcement volume */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1.5">
<CheckCircle className="h-4 w-4 text-purple-500" />
Enforcement Volume
</h3>
<MiniBarChart
buckets={enforcementBuckets}
rangeHours={hours}
barColor="bg-purple-500"
height={120}
/>
</div>
{/* Worker status */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1.5">
<Server className="h-4 w-4 text-teal-500" />
Worker Status Transitions
</h3>
<StackedBarChart
points={data.worker_status}
rangeHours={hours}
height={120}
/>
</div>
</div>
</div>
);
}
// Re-export sub-components and types for standalone use
export {
MiniBarChart,
StackedBarChart,
FailureRateCard,
StatCard,
TimeRangeSelector,
};
export type { TimeRangeHours };

View File

@@ -0,0 +1,463 @@
import { useState } from "react";
import { formatDistanceToNow } from "date-fns";
import {
ChevronDown,
ChevronRight,
History,
Filter,
ChevronLeft,
ChevronsLeft,
ChevronsRight,
} from "lucide-react";
import {
useEntityHistory,
type HistoryEntityType,
type HistoryRecord,
type HistoryQueryParams,
} from "@/hooks/useHistory";
interface EntityHistoryPanelProps {
/** The type of entity whose history to display */
entityType: HistoryEntityType;
/** The entity's primary key */
entityId: number;
/** Optional title override (default: "Change History") */
title?: string;
/** Whether the panel starts collapsed (default: true) */
defaultCollapsed?: boolean;
/** Number of items per page (default: 10) */
pageSize?: number;
}
/**
* A reusable panel that displays the change history for an entity.
*
* Queries the TimescaleDB history hypertables via the API and renders
* a timeline of changes with expandable details showing old/new values.
*/
export default function EntityHistoryPanel({
entityType,
entityId,
title = "Change History",
defaultCollapsed = true,
pageSize = 10,
}: EntityHistoryPanelProps) {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
const [page, setPage] = useState(1);
const [operationFilter, setOperationFilter] = useState<string>("");
const [fieldFilter, setFieldFilter] = useState<string>("");
const [showFilters, setShowFilters] = useState(false);
const params: HistoryQueryParams = {
page,
page_size: pageSize,
...(operationFilter ? { operation: operationFilter } : {}),
...(fieldFilter ? { changed_field: fieldFilter } : {}),
};
const { data, isLoading, error } = useEntityHistory(
entityType,
entityId,
params,
!isCollapsed && !!entityId,
);
const records = data?.data ?? [];
const pagination = data?.pagination;
const totalPages = pagination?.total_pages ?? 1;
const totalItems = pagination?.total_items ?? 0;
const handleClearFilters = () => {
setOperationFilter("");
setFieldFilter("");
setPage(1);
};
const hasActiveFilters = !!operationFilter || !!fieldFilter;
return (
<div className="bg-white rounded-lg shadow">
{/* Header — always visible */}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="w-full px-6 py-4 flex items-center justify-between border-b border-gray-200 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-2">
<History className="h-5 w-5 text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
{totalItems > 0 && !isCollapsed && (
<span className="ml-2 px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 rounded-full">
{totalItems}
</span>
)}
</div>
{isCollapsed ? (
<ChevronRight className="h-5 w-5 text-gray-400" />
) : (
<ChevronDown className="h-5 w-5 text-gray-400" />
)}
</button>
{/* Body — only when expanded */}
{!isCollapsed && (
<div className="px-6 py-4">
{/* Filter bar */}
<div className="mb-4">
<button
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700"
>
<Filter className="h-3.5 w-3.5" />
<span>Filters</span>
{hasActiveFilters && (
<span className="ml-1 h-2 w-2 rounded-full bg-blue-500" />
)}
</button>
{showFilters && (
<div className="mt-2 flex flex-wrap gap-3 items-end">
<div>
<label className="block text-xs text-gray-500 mb-1">
Operation
</label>
<select
value={operationFilter}
onChange={(e) => {
setOperationFilter(e.target.value);
setPage(1);
}}
className="text-sm border border-gray-300 rounded px-2 py-1.5 bg-white"
>
<option value="">All</option>
<option value="INSERT">INSERT</option>
<option value="UPDATE">UPDATE</option>
<option value="DELETE">DELETE</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">
Changed Field
</label>
<input
type="text"
value={fieldFilter}
onChange={(e) => {
setFieldFilter(e.target.value);
setPage(1);
}}
placeholder="e.g. status"
className="text-sm border border-gray-300 rounded px-2 py-1.5 w-36"
/>
</div>
{hasActiveFilters && (
<button
onClick={handleClearFilters}
className="text-xs text-blue-600 hover:text-blue-800 pb-1"
>
Clear filters
</button>
)}
</div>
)}
</div>
{/* Loading state */}
{isLoading && (
<div className="flex items-center justify-center py-8">
<div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600" />
</div>
)}
{/* Error state */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 rounded p-3 text-sm">
Failed to load history:{" "}
{error instanceof Error ? error.message : "Unknown error"}
</div>
)}
{/* Empty state */}
{!isLoading && !error && records.length === 0 && (
<p className="text-sm text-gray-500 py-4 text-center">
{hasActiveFilters
? "No history records match the current filters."
: "No change history recorded yet."}
</p>
)}
{/* Records list */}
{!isLoading && !error && records.length > 0 && (
<div className="space-y-1">
{records.map((record, idx) => (
<HistoryRecordRow key={`${record.time}-${idx}`} record={record} />
))}
</div>
)}
{/* Pagination */}
{!isLoading && totalPages > 1 && (
<div className="mt-4 flex items-center justify-between text-sm">
<span className="text-gray-500">
Page {page} of {totalPages} ({totalItems} records)
</span>
<div className="flex items-center gap-1">
<PaginationButton
onClick={() => setPage(1)}
disabled={page <= 1}
title="First page"
>
<ChevronsLeft className="h-4 w-4" />
</PaginationButton>
<PaginationButton
onClick={() => setPage(page - 1)}
disabled={page <= 1}
title="Previous page"
>
<ChevronLeft className="h-4 w-4" />
</PaginationButton>
<PaginationButton
onClick={() => setPage(page + 1)}
disabled={page >= totalPages}
title="Next page"
>
<ChevronRight className="h-4 w-4" />
</PaginationButton>
<PaginationButton
onClick={() => setPage(totalPages)}
disabled={page >= totalPages}
title="Last page"
>
<ChevronsRight className="h-4 w-4" />
</PaginationButton>
</div>
</div>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function PaginationButton({
onClick,
disabled,
title,
children,
}: {
onClick: () => void;
disabled: boolean;
title: string;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
title={title}
className="p-1 rounded hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
>
{children}
</button>
);
}
/**
* A single history record displayed as a collapsible row.
*/
function HistoryRecordRow({ record }: { record: HistoryRecord }) {
const [expanded, setExpanded] = useState(false);
const time = new Date(record.time);
const relativeTime = formatDistanceToNow(time, { addSuffix: true });
return (
<div className="border border-gray-100 rounded">
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-gray-50 transition-colors text-sm"
>
{/* Expand/collapse indicator */}
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 text-gray-400 flex-shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-gray-400 flex-shrink-0" />
)}
{/* Operation badge */}
<OperationBadge operation={record.operation} />
{/* Changed fields summary */}
<span className="text-gray-700 truncate flex-1">
{record.operation === "INSERT" && "Entity created"}
{record.operation === "DELETE" && "Entity deleted"}
{record.operation === "UPDATE" && record.changed_fields.length > 0 && (
<>
Changed{" "}
<span className="font-medium">
{record.changed_fields.join(", ")}
</span>
</>
)}
{record.operation === "UPDATE" &&
record.changed_fields.length === 0 &&
"Updated"}
</span>
{/* Timestamp */}
<span
className="text-xs text-gray-400 flex-shrink-0"
title={time.toISOString()}
>
{relativeTime}
</span>
</button>
{/* Expanded detail */}
{expanded && (
<div className="px-3 pb-3 pt-1 border-t border-gray-100">
{/* Timestamp detail */}
<p className="text-xs text-gray-400 mb-2">
{time.toLocaleString()} (UTC: {time.toISOString()})
</p>
{/* Field-level diffs */}
{record.operation === "UPDATE" && record.changed_fields.length > 0 && (
<div className="space-y-2">
{record.changed_fields.map((field) => (
<FieldDiff
key={field}
field={field}
oldValue={record.old_values?.[field]}
newValue={record.new_values?.[field]}
/>
))}
</div>
)}
{/* INSERT — show new_values */}
{record.operation === "INSERT" && record.new_values && (
<div>
<p className="text-xs font-medium text-gray-500 mb-1">
Initial values
</p>
<JsonBlock value={record.new_values} />
</div>
)}
{/* DELETE — show old_values if available */}
{record.operation === "DELETE" && record.old_values && (
<div>
<p className="text-xs font-medium text-gray-500 mb-1">
Values at deletion
</p>
<JsonBlock value={record.old_values} />
</div>
)}
{/* Fallback when there's nothing to show */}
{!record.old_values && !record.new_values && (
<p className="text-xs text-gray-400 italic">
No field-level details recorded.
</p>
)}
</div>
)}
</div>
);
}
/**
* Colored badge for the operation type.
*/
function OperationBadge({ operation }: { operation: string }) {
const colors: Record<string, string> = {
INSERT: "bg-green-100 text-green-700",
UPDATE: "bg-blue-100 text-blue-700",
DELETE: "bg-red-100 text-red-700",
};
return (
<span
className={`px-1.5 py-0.5 text-[10px] font-semibold rounded flex-shrink-0 ${colors[operation] ?? "bg-gray-100 text-gray-700"}`}
>
{operation}
</span>
);
}
/**
* Renders a single field's old → new diff.
*/
function FieldDiff({
field,
oldValue,
newValue,
}: {
field: string;
oldValue: unknown;
newValue: unknown;
}) {
const isSimple =
typeof oldValue !== "object" && typeof newValue !== "object";
return (
<div className="text-xs">
<p className="font-medium text-gray-600 mb-0.5">{field}</p>
{isSimple ? (
<div className="flex items-center gap-2 flex-wrap">
<span className="bg-red-50 text-red-700 px-1.5 py-0.5 rounded line-through">
{formatValue(oldValue)}
</span>
<span className="text-gray-400"></span>
<span className="bg-green-50 text-green-700 px-1.5 py-0.5 rounded">
{formatValue(newValue)}
</span>
</div>
) : (
<div className="grid grid-cols-2 gap-2">
<div>
<p className="text-[10px] text-gray-400 mb-0.5">Before</p>
<JsonBlock value={oldValue} />
</div>
<div>
<p className="text-[10px] text-gray-400 mb-0.5">After</p>
<JsonBlock value={newValue} />
</div>
</div>
)}
</div>
);
}
/**
* Format a scalar value for display.
*/
function formatValue(value: unknown): string {
if (value === null || value === undefined) return "null";
if (typeof value === "string") return value;
return JSON.stringify(value);
}
/**
* Renders a JSONB value in a code block.
*/
function JsonBlock({ value }: { value: unknown }) {
if (value === null || value === undefined) {
return <span className="text-gray-400 text-xs italic">null</span>;
}
const formatted =
typeof value === "object"
? JSON.stringify(value, null, 2)
: String(value);
return (
<pre className="bg-gray-50 rounded p-2 text-[11px] text-gray-700 overflow-x-auto max-h-48 whitespace-pre-wrap break-all">
{formatted}
</pre>
);
}