change capture
This commit is contained in:
772
web/src/components/common/AnalyticsWidgets.tsx
Normal file
772
web/src/components/common/AnalyticsWidgets.tsx
Normal 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 };
|
||||
463
web/src/components/common/EntityHistoryPanel.tsx
Normal file
463
web/src/components/common/EntityHistoryPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user