@littlebearapps/platform-admin-sdk 1.5.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/templates.d.ts +1 -1
- package/dist/templates.js +197 -2
- package/package.json +1 -1
- package/templates/full/dashboard/src/components/patterns/ActivePatterns.tsx +62 -0
- package/templates/full/dashboard/src/components/patterns/PatternTabs.tsx +116 -0
- package/templates/full/dashboard/src/components/patterns/SystemPatterns.tsx +52 -0
- package/templates/full/dashboard/src/components/patterns/index.ts +3 -0
- package/templates/full/dashboard/src/components/reports/DigestStats.tsx +151 -0
- package/templates/full/dashboard/src/components/reports/GapDetectionReport.tsx +69 -0
- package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
- package/templates/full/dashboard/src/components/reports/SdkAuditReport.tsx +72 -0
- package/templates/full/dashboard/src/components/reports/index.ts +2 -0
- package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
- package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
- package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
- package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
- package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
- package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
- package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
- package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
- package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
- package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
- package/templates/full/dashboard/src/lib/search/api.ts +258 -0
- package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
- package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
- package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
- package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
- package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
- package/templates/full/dashboard/src/pages/api/notifications/[id]/read.ts +37 -0
- package/templates/full/dashboard/src/pages/api/notifications/read-all.ts +28 -0
- package/templates/full/dashboard/src/pages/api/patterns/cache-refresh.ts +38 -0
- package/templates/full/dashboard/src/pages/api/patterns/discover.ts +36 -0
- package/templates/full/dashboard/src/pages/api/patterns/ready-for-review.ts +39 -0
- package/templates/full/dashboard/src/pages/api/patterns/stats.ts +39 -0
- package/templates/full/dashboard/src/pages/api/patterns/suggestions.ts +43 -0
- package/templates/full/dashboard/src/pages/api/reports/audit.ts +45 -0
- package/templates/full/dashboard/src/pages/api/reports/usage.ts +52 -0
- package/templates/full/dashboard/src/pages/api/search/reindex.ts +28 -0
- package/templates/full/dashboard/src/pages/api/search/stats.ts +27 -0
- package/templates/full/dashboard/src/pages/api/settings/index.ts +37 -0
- package/templates/full/dashboard/src/pages/api/settings/update.ts +41 -0
- package/templates/full/dashboard/src/pages/api/topology/index.ts +56 -0
- package/templates/full/scripts/ops/universal-backfill.ts +147 -0
- package/templates/shared/.github/workflows/contract-check.yml.hbs +42 -0
- package/templates/shared/.github/workflows/dashboard-deploy.yml.hbs +39 -0
- package/templates/shared/.github/workflows/security.yml +33 -0
- package/templates/shared/dashboard/src/components/Nav.astro.hbs +2 -0
- package/templates/shared/dashboard/src/components/infrastructure/AlertHistory.tsx +57 -0
- package/templates/shared/dashboard/src/components/infrastructure/InfrastructureStats.tsx +73 -0
- package/templates/shared/dashboard/src/components/infrastructure/ServiceRegistry.tsx +55 -0
- package/templates/shared/dashboard/src/components/infrastructure/UptimeStatus.tsx +56 -0
- package/templates/shared/dashboard/src/components/infrastructure/index.ts +4 -0
- package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
- package/templates/shared/dashboard/src/components/ui/Breadcrumbs.tsx +27 -0
- package/templates/shared/dashboard/src/components/ui/EmptyState.tsx +26 -0
- package/templates/shared/dashboard/src/components/ui/ErrorBoundary.tsx +42 -0
- package/templates/shared/dashboard/src/components/ui/LoadingSkeleton.tsx +18 -0
- package/templates/shared/dashboard/src/components/ui/PageShell.tsx +26 -0
- package/templates/shared/dashboard/src/components/ui/Toast.tsx +44 -0
- package/templates/shared/dashboard/src/components/ui/index.ts +6 -0
- package/templates/shared/dashboard/src/components/usage/AnomaliesWidget.tsx +68 -0
- package/templates/shared/dashboard/src/components/usage/HourlyUsageChart.tsx +55 -0
- package/templates/shared/dashboard/src/components/usage/PlanAllowanceDashboard.tsx +67 -0
- package/templates/shared/dashboard/src/components/usage/ProjectCostBreakdown.tsx +55 -0
- package/templates/shared/dashboard/src/components/usage/index.ts +4 -0
- package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
- package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
- package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
- package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
- package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
- package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
- package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
- package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
- package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
- package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
- package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
- package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
- package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
- package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
- package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
- package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
- package/templates/shared/dashboard/src/lib/cloudflare/costs.ts +21 -0
- package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
- package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
- package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
- package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
- package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
- package/templates/shared/dashboard/src/pages/api/costs/overview.ts +65 -0
- package/templates/shared/dashboard/src/pages/api/costs/providers.ts +47 -0
- package/templates/shared/dashboard/src/pages/api/infrastructure/services.ts +55 -0
- package/templates/shared/dashboard/src/pages/api/infrastructure/stats.ts +99 -0
- package/templates/shared/dashboard/src/pages/api/usage/allowances.ts +56 -0
- package/templates/shared/dashboard/src/pages/api/usage/anomalies.ts +45 -0
- package/templates/shared/dashboard/src/pages/api/usage/billing.ts +53 -0
- package/templates/shared/dashboard/src/pages/api/usage/granular.ts +50 -0
- package/templates/shared/dashboard/src/pages/api/usage/hourly.ts +45 -0
- package/templates/shared/dashboard/src/pages/api/usage/projects.ts +51 -0
- package/templates/shared/dashboard/src/pages/api/user/identity.ts +11 -0
- package/templates/shared/dashboard/src/pages/settings/notifications.astro +34 -0
- package/templates/shared/dashboard/src/pages/settings/thresholds.astro +39 -0
- package/templates/shared/dashboard/src/pages/settings/usage.astro +28 -0
- package/templates/shared/docs/architecture.md +89 -0
- package/templates/shared/docs/post-deploy-runbook.md +126 -0
- package/templates/shared/docs/troubleshooting.md +91 -0
- package/templates/shared/package.json.hbs +5 -0
- package/templates/shared/scripts/ops/backfill-cloudflare-daily.ts +145 -0
- package/templates/shared/scripts/ops/backfill-monthly-rollups.ts +125 -0
- package/templates/shared/scripts/ops/validate-controls.js +141 -0
- package/templates/shared/tests/contract/validate-schemas.test.ts +130 -0
- package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
- package/templates/shared/tests/fixtures/telemetry-envelope-invalid.json +9 -0
- package/templates/shared/tests/fixtures/telemetry-envelope-valid.json +27 -0
- package/templates/shared/tests/helpers/mock-d1.ts +61 -0
- package/templates/shared/tests/helpers/mock-kv.ts +37 -0
- package/templates/shared/tests/helpers/mock-storage.ts +166 -0
- package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
- package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
- package/templates/shared/tests/unit/billing.test.ts +331 -0
- package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
- package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
- package/templates/shared/tests/unit/control.test.ts +226 -0
- package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
- package/templates/shared/tests/unit/economics.test.ts +365 -0
- package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
- package/templates/shared/tests/unit/workers/batch-persistence.test.ts +133 -0
- package/templates/shared/tests/unit/workers/budget-enforcement.test.ts +214 -0
- package/templates/shared/vitest.config.ts +18 -0
- package/templates/standard/dashboard/src/components/health/CircuitBreakerEvents.tsx +69 -0
- package/templates/standard/dashboard/src/components/health/CircuitBreakerPanel.tsx +97 -0
- package/templates/standard/dashboard/src/components/health/index.ts +2 -0
- package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
- package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
- package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
- package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
- package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
- package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
- package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
- package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
- package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
- package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
- package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
- package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
- package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
- package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
- package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
- package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/mute.ts +49 -0
- package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/resolve.ts +36 -0
- package/templates/standard/dashboard/src/pages/api/errors/[fingerprint].ts +55 -0
- package/templates/standard/dashboard/src/pages/api/health/audit-history.ts +37 -0
- package/templates/standard/dashboard/src/pages/circuit-breakers.astro +13 -0
- package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
- package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
- package/templates/standard/tests/unit/error-collector/capture.test.ts +106 -0
- package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
- package/templates/standard/tests/unit/error-collector/fingerprint.test.ts +155 -0
- package/templates/standard/tests/unit/error-collector/github.test.ts +187 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UsageTable Component
|
|
3
|
+
* Industrial Command Center aesthetic - dense rows, sparklines, progress bars
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useMemo, useState } from 'react';
|
|
7
|
+
import {
|
|
8
|
+
useReactTable,
|
|
9
|
+
getCoreRowModel,
|
|
10
|
+
getSortedRowModel,
|
|
11
|
+
flexRender,
|
|
12
|
+
type ColumnDef,
|
|
13
|
+
type SortingState,
|
|
14
|
+
} from '@tanstack/react-table';
|
|
15
|
+
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
|
|
16
|
+
import { clsx } from 'clsx';
|
|
17
|
+
import {
|
|
18
|
+
type ProjectTableRow,
|
|
19
|
+
type ProjectStatus,
|
|
20
|
+
type UsageRow,
|
|
21
|
+
type HealthStatus,
|
|
22
|
+
CB_COLORS,
|
|
23
|
+
HEALTH_COLORS,
|
|
24
|
+
} from './types';
|
|
25
|
+
import { StatusBadge } from './StatusBadge';
|
|
26
|
+
|
|
27
|
+
interface UsageTableProps {
|
|
28
|
+
data: UsageRow[];
|
|
29
|
+
statusMap: Record<string, ProjectStatus>;
|
|
30
|
+
loading?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Format number with K/M suffix
|
|
35
|
+
*/
|
|
36
|
+
function formatNumber(value: number): string {
|
|
37
|
+
if (isNaN(value) || value === 0) return '0';
|
|
38
|
+
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
|
39
|
+
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
|
|
40
|
+
return value.toFixed(0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Format currency
|
|
45
|
+
*/
|
|
46
|
+
function formatCurrency(value: number): string {
|
|
47
|
+
return `$${value.toFixed(2)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Mini sparkline bar chart
|
|
52
|
+
*/
|
|
53
|
+
function Sparkline({ data, className }: { data: number[]; className?: string }) {
|
|
54
|
+
if (!data.length) return null;
|
|
55
|
+
|
|
56
|
+
const max = Math.max(...data, 1);
|
|
57
|
+
const normalised = data.map((v) => (v / max) * 100);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className={clsx('flex items-end gap-0.5 h-4', className)}>
|
|
61
|
+
{normalised.map((height, i) => (
|
|
62
|
+
<div
|
|
63
|
+
key={i}
|
|
64
|
+
className="w-1 bg-slate-600 rounded-sm transition-all"
|
|
65
|
+
style={{ height: `${Math.max(height, 5)}%` }}
|
|
66
|
+
/>
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Progress bar with threshold colours
|
|
74
|
+
*/
|
|
75
|
+
function SpendBar({ percentage, className }: { percentage: number; className?: string }) {
|
|
76
|
+
const getColour = (pct: number): string => {
|
|
77
|
+
if (pct >= 90) return 'bg-rose-500';
|
|
78
|
+
if (pct >= 75) return 'bg-amber-500';
|
|
79
|
+
if (pct >= 50) return 'bg-yellow-500';
|
|
80
|
+
return 'bg-emerald-500';
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className={clsx('w-full bg-slate-800 rounded-sm h-2 overflow-hidden', className)}>
|
|
85
|
+
<div
|
|
86
|
+
className={clsx('h-full rounded-sm transition-all', getColour(percentage))}
|
|
87
|
+
style={{ width: `${Math.min(percentage, 100)}%` }}
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Circuit breaker status dot
|
|
95
|
+
*/
|
|
96
|
+
function CBDot({ state }: { state: 'active' | 'tripped' }) {
|
|
97
|
+
return (
|
|
98
|
+
<span
|
|
99
|
+
className={clsx(
|
|
100
|
+
'inline-block w-3 h-3 rounded-full',
|
|
101
|
+
CB_COLORS[state],
|
|
102
|
+
state === 'tripped' && 'animate-pulse'
|
|
103
|
+
)}
|
|
104
|
+
title={state === 'active' ? 'Circuit breaker active' : 'Circuit breaker tripped'}
|
|
105
|
+
/>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Determine health status from lastSeen timestamp
|
|
111
|
+
*/
|
|
112
|
+
function getHealthStatus(lastSeen?: string): HealthStatus {
|
|
113
|
+
if (!lastSeen) return 'unknown';
|
|
114
|
+
|
|
115
|
+
const lastSeenDate = new Date(lastSeen);
|
|
116
|
+
const hourAgo = Date.now() - 60 * 60 * 1000;
|
|
117
|
+
|
|
118
|
+
return lastSeenDate.getTime() > hourAgo ? 'online' : 'idle';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Health indicator dot with tooltip
|
|
123
|
+
*/
|
|
124
|
+
function HealthDot({ lastSeen }: { lastSeen?: string }) {
|
|
125
|
+
const status = getHealthStatus(lastSeen);
|
|
126
|
+
const { dot, text } = HEALTH_COLORS[status];
|
|
127
|
+
|
|
128
|
+
const label = status === 'online' ? 'Online' : status === 'idle' ? 'Idle' : 'Unknown';
|
|
129
|
+
const tooltip = lastSeen
|
|
130
|
+
? `Last seen: ${new Date(lastSeen).toLocaleString()}`
|
|
131
|
+
: 'No heartbeat received';
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<span className={clsx('flex items-center gap-1', text)} title={tooltip}>
|
|
135
|
+
<span>{dot}</span>
|
|
136
|
+
<span className="text-xs hidden sm:inline">{label}</span>
|
|
137
|
+
</span>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Transform usage data + status map into table rows
|
|
143
|
+
*/
|
|
144
|
+
function transformToTableRows(
|
|
145
|
+
usageData: UsageRow[],
|
|
146
|
+
statusMap: Record<string, ProjectStatus>
|
|
147
|
+
): ProjectTableRow[] {
|
|
148
|
+
// Group usage by project
|
|
149
|
+
const projectActivity = new Map<string, { total: number; trend: number[] }>();
|
|
150
|
+
|
|
151
|
+
for (const row of usageData) {
|
|
152
|
+
const existing = projectActivity.get(row.project_id);
|
|
153
|
+
const activity =
|
|
154
|
+
(row.d1_reads || 0) +
|
|
155
|
+
(row.d1_writes || 0) +
|
|
156
|
+
(row.kv_reads || 0) +
|
|
157
|
+
(row.kv_writes || 0) +
|
|
158
|
+
(row.workers_requests || 0);
|
|
159
|
+
|
|
160
|
+
if (existing) {
|
|
161
|
+
existing.total += activity;
|
|
162
|
+
existing.trend.push(activity);
|
|
163
|
+
} else {
|
|
164
|
+
projectActivity.set(row.project_id, { total: activity, trend: [activity] });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Combine with status data
|
|
169
|
+
const rows: ProjectTableRow[] = [];
|
|
170
|
+
|
|
171
|
+
// First add projects from status map
|
|
172
|
+
for (const [projectId, status] of Object.entries(statusMap)) {
|
|
173
|
+
const activityData = projectActivity.get(projectId) || { total: 0, trend: [] };
|
|
174
|
+
rows.push({
|
|
175
|
+
id: projectId,
|
|
176
|
+
name: projectId.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
177
|
+
status: status.status,
|
|
178
|
+
activity: activityData.total,
|
|
179
|
+
activityTrend: activityData.trend.slice(-6), // Last 6 data points
|
|
180
|
+
spend: status.spend,
|
|
181
|
+
cap: status.cap,
|
|
182
|
+
percentage: status.percentage,
|
|
183
|
+
circuitBreaker: status.circuitBreaker,
|
|
184
|
+
lastSeen: status.lastSeen,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Add any projects from usage data not in status map
|
|
189
|
+
for (const [projectId, activityData] of projectActivity) {
|
|
190
|
+
if (!statusMap[projectId]) {
|
|
191
|
+
rows.push({
|
|
192
|
+
id: projectId,
|
|
193
|
+
name: projectId.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
194
|
+
status: 'RUN',
|
|
195
|
+
activity: activityData.total,
|
|
196
|
+
activityTrend: activityData.trend.slice(-6),
|
|
197
|
+
spend: 0,
|
|
198
|
+
cap: 100,
|
|
199
|
+
percentage: 0,
|
|
200
|
+
circuitBreaker: 'active',
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return rows;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Sorting indicator
|
|
210
|
+
*/
|
|
211
|
+
function SortIndicator({ isSorted }: { isSorted: false | 'asc' | 'desc' }) {
|
|
212
|
+
if (!isSorted) {
|
|
213
|
+
return <ChevronsUpDown className="w-3 h-3 text-slate-600" />;
|
|
214
|
+
}
|
|
215
|
+
return isSorted === 'asc' ? (
|
|
216
|
+
<ChevronUp className="w-3 h-3 text-slate-400" />
|
|
217
|
+
) : (
|
|
218
|
+
<ChevronDown className="w-3 h-3 text-slate-400" />
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Loading skeleton
|
|
224
|
+
*/
|
|
225
|
+
function TableSkeleton() {
|
|
226
|
+
return (
|
|
227
|
+
<div className="bg-slate-900 rounded-sm border border-slate-800 overflow-hidden">
|
|
228
|
+
<div className="animate-pulse">
|
|
229
|
+
{/* Header */}
|
|
230
|
+
<div className="h-10 bg-slate-800/50 border-b border-slate-700" />
|
|
231
|
+
{/* Rows */}
|
|
232
|
+
{[...Array(5)].map((_, i) => (
|
|
233
|
+
<div key={i} className="h-12 border-b border-slate-800 flex items-center px-4 gap-4">
|
|
234
|
+
<div className="h-4 bg-slate-800 rounded w-24" />
|
|
235
|
+
<div className="h-4 bg-slate-800 rounded w-16" />
|
|
236
|
+
<div className="flex-1 h-2 bg-slate-800 rounded" />
|
|
237
|
+
<div className="h-3 bg-slate-800 rounded-full w-3" />
|
|
238
|
+
</div>
|
|
239
|
+
))}
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function UsageTable({ data, statusMap, loading = false }: UsageTableProps) {
|
|
246
|
+
const [sorting, setSorting] = useState<SortingState>([{ id: 'percentage', desc: true }]);
|
|
247
|
+
|
|
248
|
+
const tableData = useMemo(() => transformToTableRows(data, statusMap), [data, statusMap]);
|
|
249
|
+
|
|
250
|
+
const columns = useMemo<ColumnDef<ProjectTableRow>[]>(
|
|
251
|
+
() => [
|
|
252
|
+
{
|
|
253
|
+
accessorKey: 'name',
|
|
254
|
+
header: 'Project',
|
|
255
|
+
cell: ({ row }) => (
|
|
256
|
+
<div className="flex items-center gap-2">
|
|
257
|
+
<StatusBadge status={row.original.status} size="sm" showLabel={false} />
|
|
258
|
+
<span className="font-mono text-sm text-slate-200 truncate max-w-[150px]">
|
|
259
|
+
{row.original.name}
|
|
260
|
+
</span>
|
|
261
|
+
</div>
|
|
262
|
+
),
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
accessorKey: 'activity',
|
|
266
|
+
header: 'Activity',
|
|
267
|
+
cell: ({ row }) => (
|
|
268
|
+
<div className="flex items-center gap-3">
|
|
269
|
+
{/* Hide sparkline on mobile */}
|
|
270
|
+
<Sparkline data={row.original.activityTrend} className="hidden sm:flex" />
|
|
271
|
+
<span className="font-mono text-sm text-slate-300">
|
|
272
|
+
{formatNumber(row.original.activity)}
|
|
273
|
+
</span>
|
|
274
|
+
</div>
|
|
275
|
+
),
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
accessorKey: 'percentage',
|
|
279
|
+
header: 'Spend',
|
|
280
|
+
cell: ({ row }) => (
|
|
281
|
+
<div className="flex items-center gap-3 min-w-[120px]">
|
|
282
|
+
<SpendBar percentage={row.original.percentage} className="flex-1" />
|
|
283
|
+
<span className="font-mono text-xs text-slate-400 w-12 text-right">
|
|
284
|
+
{row.original.percentage.toFixed(0)}%
|
|
285
|
+
</span>
|
|
286
|
+
</div>
|
|
287
|
+
),
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
accessorKey: 'spend',
|
|
291
|
+
header: 'Cost',
|
|
292
|
+
cell: ({ row }) => (
|
|
293
|
+
<span className="font-mono text-sm text-slate-300">
|
|
294
|
+
{formatCurrency(row.original.spend)}
|
|
295
|
+
</span>
|
|
296
|
+
),
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
accessorKey: 'circuitBreaker',
|
|
300
|
+
header: 'CB',
|
|
301
|
+
cell: ({ row }) => (
|
|
302
|
+
<div className="flex justify-center">
|
|
303
|
+
<CBDot state={row.original.circuitBreaker} />
|
|
304
|
+
</div>
|
|
305
|
+
),
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
accessorKey: 'lastSeen',
|
|
309
|
+
header: 'Health',
|
|
310
|
+
cell: ({ row }) => (
|
|
311
|
+
<div className="flex justify-center">
|
|
312
|
+
<HealthDot lastSeen={row.original.lastSeen} />
|
|
313
|
+
</div>
|
|
314
|
+
),
|
|
315
|
+
},
|
|
316
|
+
],
|
|
317
|
+
[]
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const table = useReactTable({
|
|
321
|
+
data: tableData,
|
|
322
|
+
columns,
|
|
323
|
+
state: { sorting },
|
|
324
|
+
onSortingChange: setSorting,
|
|
325
|
+
getCoreRowModel: getCoreRowModel(),
|
|
326
|
+
getSortedRowModel: getSortedRowModel(),
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
if (loading) return <TableSkeleton />;
|
|
330
|
+
|
|
331
|
+
if (tableData.length === 0) {
|
|
332
|
+
return (
|
|
333
|
+
<div className="bg-slate-900 rounded-sm border border-slate-800 p-8 text-center">
|
|
334
|
+
<p className="text-slate-500 font-mono text-sm">No project data available</p>
|
|
335
|
+
</div>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
<div className="bg-slate-900 rounded-sm border border-slate-800 overflow-hidden">
|
|
341
|
+
<table className="w-full">
|
|
342
|
+
<thead>
|
|
343
|
+
{table.getHeaderGroups().map((headerGroup) => (
|
|
344
|
+
<tr key={headerGroup.id} className="bg-slate-800/50 border-b border-slate-700">
|
|
345
|
+
{headerGroup.headers.map((header) => (
|
|
346
|
+
<th
|
|
347
|
+
key={header.id}
|
|
348
|
+
className={clsx(
|
|
349
|
+
'px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400',
|
|
350
|
+
'cursor-pointer hover:text-slate-200 transition-colors select-none'
|
|
351
|
+
)}
|
|
352
|
+
onClick={header.column.getToggleSortingHandler()}
|
|
353
|
+
>
|
|
354
|
+
<div className="flex items-center gap-1">
|
|
355
|
+
{flexRender(header.column.columnDef.header, header.getContext())}
|
|
356
|
+
<SortIndicator isSorted={header.column.getIsSorted()} />
|
|
357
|
+
</div>
|
|
358
|
+
</th>
|
|
359
|
+
))}
|
|
360
|
+
</tr>
|
|
361
|
+
))}
|
|
362
|
+
</thead>
|
|
363
|
+
<tbody>
|
|
364
|
+
{table.getRowModel().rows.map((row) => (
|
|
365
|
+
<tr
|
|
366
|
+
key={row.id}
|
|
367
|
+
className={clsx(
|
|
368
|
+
'border-b border-slate-800 hover:bg-slate-800/30 transition-colors',
|
|
369
|
+
row.original.status === 'STOP' && 'bg-rose-500/5'
|
|
370
|
+
)}
|
|
371
|
+
>
|
|
372
|
+
{row.getVisibleCells().map((cell) => (
|
|
373
|
+
<td key={cell.id} className="px-4 py-3">
|
|
374
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
375
|
+
</td>
|
|
376
|
+
))}
|
|
377
|
+
</tr>
|
|
378
|
+
))}
|
|
379
|
+
</tbody>
|
|
380
|
+
</table>
|
|
381
|
+
</div>
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export default UsageTable;
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CircuitBreakerEvents Component (React)
|
|
3
|
+
*
|
|
4
|
+
* React version of the Circuit Breaker Event Log for the unified dashboard.
|
|
5
|
+
* Displays recent circuit breaker trip/reset events.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
9
|
+
import { RefreshCw, AlertTriangle, Zap, Check, Pause, Play } from 'lucide-react';
|
|
10
|
+
import { clsx } from 'clsx';
|
|
11
|
+
import { fetchWithDedup } from '../../../lib/usage/fetchWithDedup';
|
|
12
|
+
|
|
13
|
+
interface CircuitBreakerEvent {
|
|
14
|
+
id: string;
|
|
15
|
+
featureKey: string;
|
|
16
|
+
eventType: 'trip' | 'reset' | 'manual_disable' | 'manual_enable';
|
|
17
|
+
reason: string | null;
|
|
18
|
+
violatedResource: string | null;
|
|
19
|
+
currentValue: number | null;
|
|
20
|
+
budgetLimit: number | null;
|
|
21
|
+
autoReset: boolean;
|
|
22
|
+
alertSent: boolean;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type EventTypeFilter = '' | 'trip' | 'reset';
|
|
27
|
+
|
|
28
|
+
function getEventIcon(eventType: string) {
|
|
29
|
+
switch (eventType) {
|
|
30
|
+
case 'trip':
|
|
31
|
+
return <Zap className="w-3.5 h-3.5" />;
|
|
32
|
+
case 'reset':
|
|
33
|
+
return <Check className="w-3.5 h-3.5" />;
|
|
34
|
+
case 'manual_disable':
|
|
35
|
+
return <Pause className="w-3.5 h-3.5" />;
|
|
36
|
+
case 'manual_enable':
|
|
37
|
+
return <Play className="w-3.5 h-3.5" />;
|
|
38
|
+
default:
|
|
39
|
+
return <Zap className="w-3.5 h-3.5" />;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getEventTypeLabel(eventType: string): string {
|
|
44
|
+
switch (eventType) {
|
|
45
|
+
case 'trip':
|
|
46
|
+
return 'Trip';
|
|
47
|
+
case 'reset':
|
|
48
|
+
return 'Reset';
|
|
49
|
+
case 'manual_disable':
|
|
50
|
+
return 'Manual Disable';
|
|
51
|
+
case 'manual_enable':
|
|
52
|
+
return 'Manual Enable';
|
|
53
|
+
default:
|
|
54
|
+
return eventType;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatTimeAgo(dateString: string): string {
|
|
59
|
+
const date = new Date(dateString + 'Z'); // Assume UTC
|
|
60
|
+
const now = new Date();
|
|
61
|
+
const diffMs = now.getTime() - date.getTime();
|
|
62
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
63
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
64
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
65
|
+
|
|
66
|
+
if (diffMins < 1) return 'Just now';
|
|
67
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
68
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
69
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
70
|
+
return date.toLocaleDateString();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const eventTypeColors: Record<string, { icon: string; badge: string }> = {
|
|
74
|
+
trip: {
|
|
75
|
+
icon: 'bg-rose-500/20 text-rose-400',
|
|
76
|
+
badge: 'bg-rose-500/20 text-rose-400',
|
|
77
|
+
},
|
|
78
|
+
reset: {
|
|
79
|
+
icon: 'bg-emerald-500/20 text-emerald-400',
|
|
80
|
+
badge: 'bg-emerald-500/20 text-emerald-400',
|
|
81
|
+
},
|
|
82
|
+
manual_disable: {
|
|
83
|
+
icon: 'bg-amber-500/20 text-amber-400',
|
|
84
|
+
badge: 'bg-amber-500/20 text-amber-400',
|
|
85
|
+
},
|
|
86
|
+
manual_enable: {
|
|
87
|
+
icon: 'bg-blue-500/20 text-blue-400',
|
|
88
|
+
badge: 'bg-blue-500/20 text-blue-400',
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export function CircuitBreakerEvents() {
|
|
93
|
+
const [events, setEvents] = useState<CircuitBreakerEvent[]>([]);
|
|
94
|
+
const [loading, setLoading] = useState(true);
|
|
95
|
+
const [error, setError] = useState<string | null>(null);
|
|
96
|
+
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
|
97
|
+
const [filter, setFilter] = useState<EventTypeFilter>('');
|
|
98
|
+
|
|
99
|
+
// Ref to track current filter for use in interval callback
|
|
100
|
+
const filterRef = useRef(filter);
|
|
101
|
+
|
|
102
|
+
// Keep ref in sync with state
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
filterRef.current = filter;
|
|
105
|
+
}, [filter]);
|
|
106
|
+
|
|
107
|
+
const fetchEvents = useCallback(async () => {
|
|
108
|
+
setLoading(true);
|
|
109
|
+
setError(null);
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const params = new URLSearchParams({ limit: '50' });
|
|
113
|
+
// Use ref to get current filter value
|
|
114
|
+
if (filterRef.current) {
|
|
115
|
+
params.set('eventType', filterRef.current);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const data = await fetchWithDedup<{
|
|
119
|
+
success: boolean;
|
|
120
|
+
events?: CircuitBreakerEvent[];
|
|
121
|
+
error?: string;
|
|
122
|
+
}>(`/api/usage/features/circuit-breaker-events?${params}`);
|
|
123
|
+
|
|
124
|
+
if (data.success) {
|
|
125
|
+
setEvents(data.events || []);
|
|
126
|
+
setLastUpdated(new Date());
|
|
127
|
+
} else {
|
|
128
|
+
throw new Error(data.error || 'Failed to fetch events');
|
|
129
|
+
}
|
|
130
|
+
} catch (err) {
|
|
131
|
+
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
132
|
+
} finally {
|
|
133
|
+
setLoading(false);
|
|
134
|
+
}
|
|
135
|
+
}, []); // No dependencies - uses ref for filter
|
|
136
|
+
|
|
137
|
+
// Fetch on mount and when filter changes
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
fetchEvents();
|
|
140
|
+
}, [filter, fetchEvents]);
|
|
141
|
+
|
|
142
|
+
// Separate stable interval - doesn't restart on filter change
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
const interval = setInterval(fetchEvents, 60_000);
|
|
145
|
+
return () => clearInterval(interval);
|
|
146
|
+
}, [fetchEvents]);
|
|
147
|
+
|
|
148
|
+
if (loading && events.length === 0) {
|
|
149
|
+
return (
|
|
150
|
+
<div className="bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-sm p-6">
|
|
151
|
+
<div className="flex items-center justify-center gap-2 text-gray-600 dark:text-slate-400">
|
|
152
|
+
<RefreshCw className="w-4 h-4 animate-spin" />
|
|
153
|
+
<span className="text-sm">Loading circuit breaker events...</span>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (error) {
|
|
160
|
+
return (
|
|
161
|
+
<div className="bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-sm p-6">
|
|
162
|
+
<div className="flex items-center gap-2 text-rose-400">
|
|
163
|
+
<AlertTriangle className="w-4 h-4" />
|
|
164
|
+
<span className="text-sm">{error}</span>
|
|
165
|
+
</div>
|
|
166
|
+
<button
|
|
167
|
+
type="button"
|
|
168
|
+
onClick={fetchEvents}
|
|
169
|
+
className="mt-3 px-3 py-1.5 bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 text-xs font-mono rounded-sm"
|
|
170
|
+
>
|
|
171
|
+
Retry
|
|
172
|
+
</button>
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div className="bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-sm">
|
|
179
|
+
{/* Header */}
|
|
180
|
+
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-slate-800">
|
|
181
|
+
<div className="flex items-center gap-3">
|
|
182
|
+
<h3 className="text-sm font-semibold text-gray-900 dark:text-slate-200">
|
|
183
|
+
Circuit Breaker Events
|
|
184
|
+
</h3>
|
|
185
|
+
<span className="text-xs text-gray-500 dark:text-slate-500 bg-gray-100 dark:bg-slate-800 px-2 py-0.5 rounded">
|
|
186
|
+
{events.length} event{events.length !== 1 ? 's' : ''}
|
|
187
|
+
</span>
|
|
188
|
+
</div>
|
|
189
|
+
<div className="flex items-center gap-2">
|
|
190
|
+
<select
|
|
191
|
+
value={filter}
|
|
192
|
+
onChange={(e) => setFilter(e.target.value as EventTypeFilter)}
|
|
193
|
+
className="bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-sm px-2 py-1 text-xs text-gray-700 dark:text-slate-300"
|
|
194
|
+
>
|
|
195
|
+
<option value="">All events</option>
|
|
196
|
+
<option value="trip">Trips only</option>
|
|
197
|
+
<option value="reset">Resets only</option>
|
|
198
|
+
</select>
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
onClick={fetchEvents}
|
|
202
|
+
disabled={loading}
|
|
203
|
+
className="p-1.5 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-sm transition-colors"
|
|
204
|
+
title="Refresh"
|
|
205
|
+
>
|
|
206
|
+
<RefreshCw
|
|
207
|
+
className={clsx(
|
|
208
|
+
'w-4 h-4 text-gray-600 dark:text-slate-400',
|
|
209
|
+
loading && 'animate-spin'
|
|
210
|
+
)}
|
|
211
|
+
/>
|
|
212
|
+
</button>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
{/* Empty State */}
|
|
217
|
+
{events.length === 0 && (
|
|
218
|
+
<div className="p-8 text-center">
|
|
219
|
+
<div className="text-3xl mb-3">⚡</div>
|
|
220
|
+
<h4 className="text-sm font-semibold text-gray-900 dark:text-slate-200 mb-1">
|
|
221
|
+
No circuit breaker events
|
|
222
|
+
</h4>
|
|
223
|
+
<p className="text-xs text-gray-500 dark:text-slate-500 max-w-xs mx-auto">
|
|
224
|
+
Events will appear here when circuit breakers trip or reset due to budget violations.
|
|
225
|
+
</p>
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
|
|
229
|
+
{/* Events List */}
|
|
230
|
+
{events.length > 0 && (
|
|
231
|
+
<div className="max-h-96 overflow-y-auto">
|
|
232
|
+
<div className="p-4 space-y-3">
|
|
233
|
+
{events.map((event) => {
|
|
234
|
+
const colors = eventTypeColors[event.eventType] || eventTypeColors.trip;
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<div
|
|
238
|
+
key={event.id}
|
|
239
|
+
className="flex items-start gap-3 p-3 bg-gray-100/30 dark:bg-slate-800/30 border border-gray-200/50 dark:border-slate-800/50 rounded-sm hover:border-gray-300 dark:hover:border-slate-700 transition-colors"
|
|
240
|
+
>
|
|
241
|
+
{/* Icon */}
|
|
242
|
+
<div
|
|
243
|
+
className={clsx(
|
|
244
|
+
'flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center',
|
|
245
|
+
colors.icon
|
|
246
|
+
)}
|
|
247
|
+
>
|
|
248
|
+
{getEventIcon(event.eventType)}
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
{/* Content */}
|
|
252
|
+
<div className="flex-1 min-w-0">
|
|
253
|
+
{/* Header */}
|
|
254
|
+
<div className="flex items-baseline gap-2 mb-1">
|
|
255
|
+
<span className="text-sm font-medium text-gray-800 dark:text-slate-200 truncate">
|
|
256
|
+
{event.featureKey}
|
|
257
|
+
</span>
|
|
258
|
+
<span
|
|
259
|
+
className={clsx(
|
|
260
|
+
'text-[10px] font-semibold uppercase px-1.5 py-0.5 rounded',
|
|
261
|
+
colors.badge
|
|
262
|
+
)}
|
|
263
|
+
>
|
|
264
|
+
{getEventTypeLabel(event.eventType)}
|
|
265
|
+
</span>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
{/* Reason */}
|
|
269
|
+
{event.reason && (
|
|
270
|
+
<p className="text-xs text-gray-600 dark:text-slate-400 mb-1 truncate">
|
|
271
|
+
{event.reason}
|
|
272
|
+
</p>
|
|
273
|
+
)}
|
|
274
|
+
|
|
275
|
+
{/* Meta */}
|
|
276
|
+
<div className="flex items-center gap-3 text-[10px] text-gray-500 dark:text-slate-500">
|
|
277
|
+
<span>{formatTimeAgo(event.createdAt)}</span>
|
|
278
|
+
{event.autoReset && (
|
|
279
|
+
<span className="px-1.5 py-0.5 bg-indigo-500/20 text-indigo-400 rounded uppercase font-semibold">
|
|
280
|
+
Auto-reset
|
|
281
|
+
</span>
|
|
282
|
+
)}
|
|
283
|
+
{event.alertSent && (
|
|
284
|
+
<span className="px-1.5 py-0.5 bg-pink-500/20 text-pink-400 rounded uppercase font-semibold">
|
|
285
|
+
Alert sent
|
|
286
|
+
</span>
|
|
287
|
+
)}
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
})}
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
|
|
297
|
+
{/* Footer */}
|
|
298
|
+
<div className="px-4 py-2 border-t border-gray-200 dark:border-slate-800 text-[10px] text-gray-500 dark:text-slate-500">
|
|
299
|
+
Last updated: {lastUpdated ? lastUpdated.toLocaleTimeString() : '--'}
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export default CircuitBreakerEvents;
|