@littlebearapps/platform-admin-sdk 2.0.0 → 2.2.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 +4 -7
- package/dist/templates.d.ts +1 -1
- package/dist/templates.js +206 -4
- package/package.json +1 -1
- package/templates/full/dashboard/src/components/notifications/NotificationDropdown.tsx +130 -0
- package/templates/full/dashboard/src/components/notifications/NotificationItem.tsx +264 -0
- package/templates/full/dashboard/src/components/patterns/PatternInfoButton.tsx +60 -0
- package/templates/full/dashboard/src/components/reports/DigestStats.tsx +151 -0
- package/templates/full/dashboard/src/components/reports/FeatureUsageReport.tsx +339 -0
- package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
- package/templates/full/dashboard/src/components/search/SearchResultGroup.tsx +46 -0
- package/templates/full/dashboard/src/components/search/SearchResultItem.tsx +212 -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/patterns/[id]/approve.ts +49 -0
- package/templates/full/dashboard/src/pages/api/patterns/[id]/reject.ts +50 -0
- package/templates/full/dashboard/src/pages/api/reports/digests/stats.ts +38 -0
- package/templates/full/dashboard/src/pages/api/reports/digests.ts +39 -0
- package/templates/full/dashboard/src/pages/api/search/reindex/[type].ts +56 -0
- package/templates/full/dashboard/src/pages/api/test-reports/[id].ts +102 -0
- package/templates/full/dashboard/src/pages/feedback.astro +365 -0
- package/templates/full/dashboard/src/pages/kiosk.astro +206 -0
- package/templates/full/dashboard/src/pages/map.astro +561 -0
- package/templates/full/dashboard/src/pages/revenue.astro +72 -0
- package/templates/full/dashboard/src/pages/tests.astro +431 -0
- package/templates/full/scripts/ops/audit-cost-anomaly.ts +430 -0
- package/templates/full/scripts/ops/verify-account-total.ts +256 -0
- package/templates/full/tests/integration/feedback-schema.test.ts +361 -0
- package/templates/full/tests/integration/r2-archive.test.ts +108 -0
- package/templates/shared/.github/workflows/dependabot-automerge.yml +41 -0
- package/templates/shared/.github/workflows/validate-controls.yml +27 -0
- package/templates/shared/dashboard/src/components/Breadcrumbs.astro +101 -0
- package/templates/shared/dashboard/src/components/EmptyState.astro +46 -0
- package/templates/shared/dashboard/src/components/ErrorBoundary.astro +79 -0
- package/templates/shared/dashboard/src/components/LoadingSkeleton.astro +105 -0
- package/templates/shared/dashboard/src/components/PageShell.astro +72 -0
- package/templates/shared/dashboard/src/components/SkipLinks.astro +22 -0
- package/templates/shared/dashboard/src/components/Toast.astro +170 -0
- package/templates/shared/dashboard/src/components/ToastContainer.astro +156 -0
- package/templates/shared/dashboard/src/components/costs/ProviderCostsGrid.tsx +401 -0
- package/templates/shared/dashboard/src/components/costs/index.ts +4 -0
- package/templates/shared/dashboard/src/components/overview/AlertBanner.tsx +94 -0
- package/templates/shared/dashboard/src/components/overview/index.ts +9 -0
- package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
- package/templates/shared/dashboard/src/components/resources/CostChart.tsx +170 -0
- package/templates/shared/dashboard/src/components/resources/ProviderCard.tsx +272 -0
- package/templates/shared/dashboard/src/components/resources/ProviderDetail.tsx +293 -0
- package/templates/shared/dashboard/src/components/settings/SettingsCard.astro +102 -0
- package/templates/shared/dashboard/src/components/usage/AllowanceGauge.astro +170 -0
- package/templates/shared/dashboard/src/components/usage/AnomalyAlerts.astro +633 -0
- package/templates/shared/dashboard/src/components/usage/BillingCycleCountdown.astro +192 -0
- package/templates/shared/dashboard/src/components/usage/BurnRateHero.astro +539 -0
- package/templates/shared/dashboard/src/components/usage/CircuitBreakerEventLog.astro +542 -0
- package/templates/shared/dashboard/src/components/usage/CircuitBreakerPanel.tsx +292 -0
- package/templates/shared/dashboard/src/components/usage/CircuitBreakerStatus.astro +669 -0
- package/templates/shared/dashboard/src/components/usage/CompactThresholdBanner.astro +531 -0
- package/templates/shared/dashboard/src/components/usage/ComparisonModeSelector.astro +651 -0
- package/templates/shared/dashboard/src/components/usage/CostBreakdownChart.astro +381 -0
- package/templates/shared/dashboard/src/components/usage/CostBreakdownTable.astro +210 -0
- package/templates/shared/dashboard/src/components/usage/CostDataTable.astro +0 -0
- package/templates/shared/dashboard/src/components/usage/CostDonutChart.astro +311 -0
- package/templates/shared/dashboard/src/components/usage/DailyCostChart.astro +632 -0
- package/templates/shared/dashboard/src/components/usage/ExportButton.astro +114 -0
- package/templates/shared/dashboard/src/components/usage/FeatureBudgetsTable.astro +872 -0
- package/templates/shared/dashboard/src/components/usage/FilterBar.astro +190 -0
- package/templates/shared/dashboard/src/components/usage/FilterToggles.astro +175 -0
- package/templates/shared/dashboard/src/components/usage/GitHubUsageCard.astro +537 -0
- package/templates/shared/dashboard/src/components/usage/OverageCostCard.astro +212 -0
- package/templates/shared/dashboard/src/components/usage/PlanUtilizationCard.astro +193 -0
- package/templates/shared/dashboard/src/components/usage/ProjectCard.astro +640 -0
- package/templates/shared/dashboard/src/components/usage/ProjectCardsGrid.astro +272 -0
- package/templates/shared/dashboard/src/components/usage/ResourceSearch.astro +279 -0
- package/templates/shared/dashboard/src/components/usage/ServiceUtilizationList.astro +604 -0
- package/templates/shared/dashboard/src/components/usage/SparklineCard.astro +399 -0
- package/templates/shared/dashboard/src/components/usage/StatsHero.astro +600 -0
- package/templates/shared/dashboard/src/components/usage/TableFilters.astro +1033 -0
- package/templates/shared/dashboard/src/components/usage/ThresholdAlert.astro +271 -0
- package/templates/shared/dashboard/src/components/usage/ThresholdSettings.astro +618 -0
- package/templates/shared/dashboard/src/components/usage/TopSpenderCard.astro +170 -0
- package/templates/shared/dashboard/src/components/usage/UnifiedResourceTable.astro +1737 -0
- package/templates/shared/dashboard/src/components/usage/UsageCard.astro +135 -0
- package/templates/shared/dashboard/src/components/usage/UsageHealthBanner.astro +387 -0
- package/templates/shared/dashboard/src/components/usage/UtilizationBar.astro +159 -0
- package/templates/shared/dashboard/src/components/usage/WorkersBreakdownTable.astro +659 -0
- package/templates/shared/dashboard/src/components/usage/daily/CostChart.astro +461 -0
- package/templates/shared/dashboard/src/components/usage/daily/CostTable.astro +946 -0
- package/templates/shared/dashboard/src/components/usage/daily/DailyOverview.astro +1079 -0
- package/templates/shared/dashboard/src/components/usage/design-tokens.ts +187 -0
- package/templates/shared/dashboard/src/components/usage/filters/InlineDateRange.astro +285 -0
- package/templates/shared/dashboard/src/components/usage/filters/PeriodButtons.astro +157 -0
- package/templates/shared/dashboard/src/components/usage/filters/ProjectSelect.astro +284 -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/scripts/ai-tab-controller.ts +419 -0
- package/templates/shared/dashboard/src/components/usage/scripts/constants.ts +60 -0
- package/templates/shared/dashboard/src/components/usage/scripts/formatters.ts +62 -0
- package/templates/shared/dashboard/src/components/usage/scripts/overview-controller.ts +1633 -0
- package/templates/shared/dashboard/src/components/usage/scripts/resource-table-builder.ts +294 -0
- package/templates/shared/dashboard/src/components/usage/scripts/tabs-filters-controller.ts +464 -0
- package/templates/shared/dashboard/src/components/usage/state/index.ts +55 -0
- package/templates/shared/dashboard/src/components/usage/state/usageActions.ts +439 -0
- package/templates/shared/dashboard/src/components/usage/state/usageStore.ts +376 -0
- package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
- package/templates/shared/dashboard/src/components/usage/types.ts +283 -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/components/usage/usage-colors.ts +292 -0
- package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -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/usage/ai-models.ts +235 -0
- package/templates/shared/dashboard/src/pages/api/usage/billing-context.ts +296 -0
- package/templates/shared/scripts/test-telemetry-flow.ts +464 -0
- package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
- package/templates/shared/tests/e2e/usage-export.test.ts +784 -0
- package/templates/shared/tests/e2e/usage-mobile.test.ts +531 -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/standard/dashboard/src/components/errors/PriorityBadge.astro +27 -0
- package/templates/standard/dashboard/src/components/infrastructure/HealthchecksStatus.tsx +293 -0
- package/templates/standard/dashboard/src/components/infrastructure/InfrastructureTabs.tsx +268 -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/analytics.astro +64 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/alerts.ts +85 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks/[id]/flips.ts +110 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks.ts +101 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/uptime/[id]/response-times.ts +121 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/uptime.ts +89 -0
- package/templates/standard/dashboard/src/pages/api/test/service-auth.ts +178 -0
- package/templates/standard/tests/integration/connectors.test.ts +241 -0
- package/templates/standard/tests/integration/github-monitor.test.ts +143 -0
- package/templates/standard/tests/integration/ingestion.test.ts +211 -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/dedup.test.ts +350 -0
- package/templates/standard/tests/unit/error-collector/github.test.ts +187 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiveHeader Component
|
|
3
|
+
*
|
|
4
|
+
* Header bar with live indicator, period buttons, search, and export.
|
|
5
|
+
* Industrial Command Centre aesthetic.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
9
|
+
import { Activity, RefreshCw, Search, Download, X } from 'lucide-react';
|
|
10
|
+
import { clsx } from 'clsx';
|
|
11
|
+
import type { Period } from './types';
|
|
12
|
+
|
|
13
|
+
interface LiveHeaderProps {
|
|
14
|
+
period: Period;
|
|
15
|
+
onPeriodChange: (period: Period) => void;
|
|
16
|
+
search: string;
|
|
17
|
+
onSearchChange: (value: string) => void;
|
|
18
|
+
isRefreshing: boolean;
|
|
19
|
+
onExport: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Live indicator with pulse animation
|
|
24
|
+
*/
|
|
25
|
+
function LiveIndicator({ isRefreshing }: { isRefreshing: boolean }) {
|
|
26
|
+
return (
|
|
27
|
+
<div className="flex items-center gap-2">
|
|
28
|
+
{isRefreshing ? (
|
|
29
|
+
<RefreshCw className="w-3.5 h-3.5 text-gray-600 dark:text-slate-400 animate-spin" />
|
|
30
|
+
) : (
|
|
31
|
+
<span className="relative flex h-2.5 w-2.5">
|
|
32
|
+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
|
33
|
+
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500" />
|
|
34
|
+
</span>
|
|
35
|
+
)}
|
|
36
|
+
<span className="text-xs font-mono text-gray-500 dark:text-slate-500 uppercase tracking-wider">
|
|
37
|
+
{isRefreshing ? 'Updating' : 'Live'}
|
|
38
|
+
</span>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Period button component
|
|
45
|
+
*/
|
|
46
|
+
function PeriodButton({
|
|
47
|
+
period,
|
|
48
|
+
currentPeriod,
|
|
49
|
+
onClick,
|
|
50
|
+
}: {
|
|
51
|
+
period: Period;
|
|
52
|
+
currentPeriod: Period;
|
|
53
|
+
onClick: (p: Period) => void;
|
|
54
|
+
}) {
|
|
55
|
+
const isActive = period === currentPeriod;
|
|
56
|
+
return (
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
onClick={() => onClick(period)}
|
|
60
|
+
className={clsx(
|
|
61
|
+
'px-3 py-1.5 text-xs font-mono font-semibold uppercase tracking-wider rounded-sm transition-all',
|
|
62
|
+
isActive
|
|
63
|
+
? 'bg-gray-200 dark:bg-slate-700 text-gray-900 dark:text-slate-100 shadow-inner'
|
|
64
|
+
: 'bg-gray-100 dark:bg-slate-800 text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-700 dark:hover:text-slate-300'
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
67
|
+
{period}
|
|
68
|
+
</button>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function LiveHeader({
|
|
73
|
+
period,
|
|
74
|
+
onPeriodChange,
|
|
75
|
+
search,
|
|
76
|
+
onSearchChange,
|
|
77
|
+
isRefreshing,
|
|
78
|
+
onExport,
|
|
79
|
+
}: LiveHeaderProps) {
|
|
80
|
+
const [localSearch, setLocalSearch] = useState(search);
|
|
81
|
+
|
|
82
|
+
// Debounce search
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
const timer = setTimeout(() => {
|
|
85
|
+
if (localSearch !== search) {
|
|
86
|
+
onSearchChange(localSearch);
|
|
87
|
+
}
|
|
88
|
+
}, 300);
|
|
89
|
+
|
|
90
|
+
return () => clearTimeout(timer);
|
|
91
|
+
}, [localSearch, search, onSearchChange]);
|
|
92
|
+
|
|
93
|
+
// Sync external search changes
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
setLocalSearch(search);
|
|
96
|
+
}, [search]);
|
|
97
|
+
|
|
98
|
+
const handleClearSearch = useCallback(() => {
|
|
99
|
+
setLocalSearch('');
|
|
100
|
+
onSearchChange('');
|
|
101
|
+
}, [onSearchChange]);
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<header className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
105
|
+
{/* Left: Title and Live Indicator */}
|
|
106
|
+
<div className="flex items-center gap-3">
|
|
107
|
+
<Activity className="w-5 h-5 text-gray-600 dark:text-slate-400" />
|
|
108
|
+
<h1 className="text-xl font-semibold text-gray-900 dark:text-slate-100 tracking-tight">
|
|
109
|
+
Usage Monitor
|
|
110
|
+
</h1>
|
|
111
|
+
<LiveIndicator isRefreshing={isRefreshing} />
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{/* Right: Controls */}
|
|
115
|
+
<div className="flex items-center gap-3">
|
|
116
|
+
{/* Period Buttons */}
|
|
117
|
+
<div className="flex items-center gap-1">
|
|
118
|
+
<PeriodButton period="24h" currentPeriod={period} onClick={onPeriodChange} />
|
|
119
|
+
<PeriodButton period="7d" currentPeriod={period} onClick={onPeriodChange} />
|
|
120
|
+
<PeriodButton period="30d" currentPeriod={period} onClick={onPeriodChange} />
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{/* Search Input */}
|
|
124
|
+
<div className="relative">
|
|
125
|
+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500 dark:text-slate-500" />
|
|
126
|
+
<input
|
|
127
|
+
type="text"
|
|
128
|
+
value={localSearch}
|
|
129
|
+
onChange={(e) => setLocalSearch(e.target.value)}
|
|
130
|
+
placeholder="Search projects..."
|
|
131
|
+
className={clsx(
|
|
132
|
+
'w-40 pl-8 pr-7 py-1.5 text-xs font-mono',
|
|
133
|
+
'bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-sm',
|
|
134
|
+
'text-gray-800 dark:text-slate-200 placeholder:text-gray-400 dark:placeholder:text-slate-500',
|
|
135
|
+
'focus:outline-none focus:border-gray-400 dark:focus:border-slate-600 focus:ring-1 focus:ring-gray-400 dark:focus:ring-slate-600',
|
|
136
|
+
'transition-all'
|
|
137
|
+
)}
|
|
138
|
+
/>
|
|
139
|
+
{localSearch && (
|
|
140
|
+
<button
|
|
141
|
+
type="button"
|
|
142
|
+
onClick={handleClearSearch}
|
|
143
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 dark:text-slate-500 hover:text-gray-700 dark:hover:text-slate-300 transition-colors"
|
|
144
|
+
>
|
|
145
|
+
<X className="w-3.5 h-3.5" />
|
|
146
|
+
</button>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
{/* Export Button */}
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
onClick={onExport}
|
|
154
|
+
className={clsx(
|
|
155
|
+
'flex items-center gap-1.5 px-3 py-1.5 text-xs font-mono font-semibold uppercase tracking-wider',
|
|
156
|
+
'bg-gray-100 dark:bg-slate-800 text-gray-600 dark:text-slate-400 rounded-sm',
|
|
157
|
+
'hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-700 dark:hover:text-slate-300 transition-all'
|
|
158
|
+
)}
|
|
159
|
+
title="Export to CSV"
|
|
160
|
+
>
|
|
161
|
+
<Download className="w-3.5 h-3.5" />
|
|
162
|
+
<span className="hidden sm:inline">Export</span>
|
|
163
|
+
</button>
|
|
164
|
+
</div>
|
|
165
|
+
</header>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export default LiveHeader;
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProjectsTable Component
|
|
3
|
+
*
|
|
4
|
+
* Hierarchical table with expandable rows for project resource breakdown.
|
|
5
|
+
* Industrial Command Centre aesthetic - data-dense, sortable columns.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useMemo, useCallback } from 'react';
|
|
9
|
+
import { ChevronRight, ChevronDown, ArrowUpDown, ArrowUp, ArrowDown, Clock } from 'lucide-react';
|
|
10
|
+
import { clsx } from 'clsx';
|
|
11
|
+
import type {
|
|
12
|
+
ProjectTableRow,
|
|
13
|
+
ProjectBreakdown,
|
|
14
|
+
SortField,
|
|
15
|
+
SortDir,
|
|
16
|
+
OperationalStatus,
|
|
17
|
+
CircuitBreakerState,
|
|
18
|
+
} from './types';
|
|
19
|
+
import { STATUS_COLORS, CB_COLORS } from './types';
|
|
20
|
+
import { ResourceBreakdown } from './ResourceBreakdown';
|
|
21
|
+
import { Sparkline } from './Sparkline';
|
|
22
|
+
|
|
23
|
+
interface ProjectsTableProps {
|
|
24
|
+
rows: ProjectTableRow[];
|
|
25
|
+
expanded: Set<string>;
|
|
26
|
+
onExpand: (projectId: string) => void;
|
|
27
|
+
onSort: (field: SortField) => void;
|
|
28
|
+
sort: SortField;
|
|
29
|
+
sortDir: SortDir;
|
|
30
|
+
resourceCache: Map<string, ProjectBreakdown>;
|
|
31
|
+
fetchResourceBreakdown: (projectId: string) => Promise<ProjectBreakdown | null>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatCost(cost: number): string {
|
|
35
|
+
if (cost >= 1000) return `$${(cost / 1000).toFixed(1)}K`;
|
|
36
|
+
if (cost >= 1) return `$${cost.toFixed(2)}`;
|
|
37
|
+
if (cost >= 0.01) return `$${cost.toFixed(3)}`;
|
|
38
|
+
return `$${cost.toFixed(4)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatDelta(pct: number): { text: string; color: string } {
|
|
42
|
+
if (pct > 0) {
|
|
43
|
+
return { text: `+${pct.toFixed(1)}%`, color: 'text-rose-400' };
|
|
44
|
+
}
|
|
45
|
+
if (pct < 0) {
|
|
46
|
+
return { text: `${pct.toFixed(1)}%`, color: 'text-emerald-400' };
|
|
47
|
+
}
|
|
48
|
+
return { text: '0%', color: 'text-gray-500 dark:text-slate-500' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Status Badge Component
|
|
53
|
+
*/
|
|
54
|
+
function StatusBadge({ status }: { status: OperationalStatus }) {
|
|
55
|
+
const colors = STATUS_COLORS[status];
|
|
56
|
+
return (
|
|
57
|
+
<span
|
|
58
|
+
className={clsx(
|
|
59
|
+
'inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-mono font-semibold uppercase',
|
|
60
|
+
colors.bg,
|
|
61
|
+
colors.text
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
<span className={clsx('w-1.5 h-1.5 rounded-full', colors.text.replace('text-', 'bg-'))} />
|
|
65
|
+
{status}
|
|
66
|
+
</span>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Circuit Breaker Indicator
|
|
72
|
+
*/
|
|
73
|
+
function CircuitBreakerIndicator({ state }: { state: CircuitBreakerState }) {
|
|
74
|
+
const label = state === 'active' ? 'CB' : state === 'tripped' ? 'TRIP' : 'No CB';
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className="flex items-center gap-1.5" title={`Circuit Breaker: ${state}`}>
|
|
78
|
+
<span className={clsx('w-2 h-2 rounded-full', CB_COLORS[state])} />
|
|
79
|
+
<span
|
|
80
|
+
className={clsx(
|
|
81
|
+
'text-xs font-mono',
|
|
82
|
+
state === 'disabled'
|
|
83
|
+
? 'text-gray-400 dark:text-slate-600'
|
|
84
|
+
: 'text-gray-500 dark:text-slate-500'
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
{label}
|
|
88
|
+
</span>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Heartbeat Indicator - shows last seen timestamp with freshness status
|
|
95
|
+
*/
|
|
96
|
+
function HeartbeatIndicator({ lastSeen }: { lastSeen?: string }) {
|
|
97
|
+
if (!lastSeen) {
|
|
98
|
+
return (
|
|
99
|
+
<div className="flex items-center gap-1.5" title="No heartbeat received">
|
|
100
|
+
<Clock className="w-3 h-3 text-gray-400 dark:text-slate-600" />
|
|
101
|
+
<span className="text-xs font-mono text-gray-400 dark:text-slate-600">—</span>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const lastSeenDate = new Date(lastSeen);
|
|
107
|
+
const now = new Date();
|
|
108
|
+
const diffMs = now.getTime() - lastSeenDate.getTime();
|
|
109
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
110
|
+
const diffHours = Math.floor(diffMs / 3600000);
|
|
111
|
+
|
|
112
|
+
// Determine freshness: < 10min = fresh, < 1hr = recent, < 24hr = stale, > 24hr = dead
|
|
113
|
+
let status: 'fresh' | 'recent' | 'stale' | 'dead';
|
|
114
|
+
let label: string;
|
|
115
|
+
|
|
116
|
+
if (diffMins < 10) {
|
|
117
|
+
status = 'fresh';
|
|
118
|
+
label = diffMins <= 1 ? 'now' : `${diffMins}m`;
|
|
119
|
+
} else if (diffMins < 60) {
|
|
120
|
+
status = 'recent';
|
|
121
|
+
label = `${diffMins}m`;
|
|
122
|
+
} else if (diffHours < 24) {
|
|
123
|
+
status = 'stale';
|
|
124
|
+
label = `${diffHours}h`;
|
|
125
|
+
} else {
|
|
126
|
+
status = 'dead';
|
|
127
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
128
|
+
label = `${diffDays}d`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const statusColors = {
|
|
132
|
+
fresh: 'text-emerald-400',
|
|
133
|
+
recent: 'text-amber-400',
|
|
134
|
+
stale: 'text-orange-400',
|
|
135
|
+
dead: 'text-rose-400',
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const dotColors = {
|
|
139
|
+
fresh: 'bg-emerald-500',
|
|
140
|
+
recent: 'bg-amber-500',
|
|
141
|
+
stale: 'bg-orange-500',
|
|
142
|
+
dead: 'bg-rose-500',
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div
|
|
147
|
+
className="flex items-center gap-1.5"
|
|
148
|
+
title={`Last heartbeat: ${lastSeenDate.toLocaleString()}`}
|
|
149
|
+
>
|
|
150
|
+
<span className={clsx('w-2 h-2 rounded-full', dotColors[status])} />
|
|
151
|
+
<span className={clsx('text-xs font-mono', statusColors[status])}>{label}</span>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Activity Bar Component
|
|
158
|
+
*/
|
|
159
|
+
function ActivityBar({ value, max = 100 }: { value: number; max?: number }) {
|
|
160
|
+
const pct = Math.min((value / max) * 100, 100);
|
|
161
|
+
const barColor =
|
|
162
|
+
pct >= 90
|
|
163
|
+
? 'bg-rose-500'
|
|
164
|
+
: pct >= 75
|
|
165
|
+
? 'bg-orange-500'
|
|
166
|
+
: pct >= 50
|
|
167
|
+
? 'bg-amber-500'
|
|
168
|
+
: 'bg-emerald-500';
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<div className="w-20 h-1.5 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
|
172
|
+
<div
|
|
173
|
+
className={clsx('h-full rounded-full transition-all', barColor)}
|
|
174
|
+
style={{ width: `${pct}%` }}
|
|
175
|
+
/>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Sortable Column Header
|
|
182
|
+
*/
|
|
183
|
+
function SortHeader({
|
|
184
|
+
field,
|
|
185
|
+
label,
|
|
186
|
+
currentSort,
|
|
187
|
+
sortDir,
|
|
188
|
+
onSort,
|
|
189
|
+
align = 'left',
|
|
190
|
+
}: {
|
|
191
|
+
field: SortField;
|
|
192
|
+
label: string;
|
|
193
|
+
currentSort: SortField;
|
|
194
|
+
sortDir: SortDir;
|
|
195
|
+
onSort: (field: SortField) => void;
|
|
196
|
+
align?: 'left' | 'right';
|
|
197
|
+
}) {
|
|
198
|
+
const isActive = field === currentSort;
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
onClick={() => onSort(field)}
|
|
204
|
+
className={clsx(
|
|
205
|
+
'flex items-center gap-1 text-xs font-mono uppercase tracking-wider transition-colors',
|
|
206
|
+
isActive
|
|
207
|
+
? 'text-gray-800 dark:text-slate-200'
|
|
208
|
+
: 'text-gray-500 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-400',
|
|
209
|
+
align === 'right' && 'flex-row-reverse'
|
|
210
|
+
)}
|
|
211
|
+
>
|
|
212
|
+
<span>{label}</span>
|
|
213
|
+
{isActive ? (
|
|
214
|
+
sortDir === 'asc' ? (
|
|
215
|
+
<ArrowUp className="w-3 h-3" />
|
|
216
|
+
) : (
|
|
217
|
+
<ArrowDown className="w-3 h-3" />
|
|
218
|
+
)
|
|
219
|
+
) : (
|
|
220
|
+
<ArrowUpDown className="w-3 h-3 opacity-50" />
|
|
221
|
+
)}
|
|
222
|
+
</button>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Project Row Component
|
|
228
|
+
*/
|
|
229
|
+
function ProjectRow({
|
|
230
|
+
row,
|
|
231
|
+
isExpanded,
|
|
232
|
+
onToggle,
|
|
233
|
+
breakdown,
|
|
234
|
+
isLoadingBreakdown,
|
|
235
|
+
}: {
|
|
236
|
+
row: ProjectTableRow;
|
|
237
|
+
isExpanded: boolean;
|
|
238
|
+
onToggle: () => void;
|
|
239
|
+
breakdown: ProjectBreakdown | null;
|
|
240
|
+
isLoadingBreakdown: boolean;
|
|
241
|
+
}) {
|
|
242
|
+
const delta = formatDelta(row.costDeltaPct);
|
|
243
|
+
|
|
244
|
+
const handleRowClick = (e: React.MouseEvent) => {
|
|
245
|
+
e.preventDefault();
|
|
246
|
+
e.stopPropagation();
|
|
247
|
+
onToggle();
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<>
|
|
252
|
+
{/* Main Row */}
|
|
253
|
+
<tr
|
|
254
|
+
className={clsx(
|
|
255
|
+
'border-b border-gray-200 dark:border-slate-800 transition-colors cursor-pointer',
|
|
256
|
+
isExpanded
|
|
257
|
+
? 'bg-gray-100/50 dark:bg-slate-800/50'
|
|
258
|
+
: 'hover:bg-gray-100/30 dark:hover:bg-slate-800/30'
|
|
259
|
+
)}
|
|
260
|
+
onClick={handleRowClick}
|
|
261
|
+
>
|
|
262
|
+
{/* Expand Chevron */}
|
|
263
|
+
<td className="px-3 py-3 w-10">
|
|
264
|
+
<button
|
|
265
|
+
type="button"
|
|
266
|
+
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-slate-700 transition-colors"
|
|
267
|
+
onClick={(e) => {
|
|
268
|
+
e.preventDefault();
|
|
269
|
+
e.stopPropagation();
|
|
270
|
+
onToggle();
|
|
271
|
+
}}
|
|
272
|
+
>
|
|
273
|
+
{isExpanded ? (
|
|
274
|
+
<ChevronDown className="w-4 h-4 text-gray-600 dark:text-slate-400" />
|
|
275
|
+
) : (
|
|
276
|
+
<ChevronRight className="w-4 h-4 text-gray-500 dark:text-slate-500" />
|
|
277
|
+
)}
|
|
278
|
+
</button>
|
|
279
|
+
</td>
|
|
280
|
+
|
|
281
|
+
{/* Project Name */}
|
|
282
|
+
<td className="px-3 py-3">
|
|
283
|
+
<span className="text-sm font-medium text-gray-800 dark:text-slate-200">{row.name}</span>
|
|
284
|
+
</td>
|
|
285
|
+
|
|
286
|
+
{/* Cost */}
|
|
287
|
+
<td className="px-3 py-3 text-right">
|
|
288
|
+
<span className="text-sm font-mono text-gray-800 dark:text-slate-200">
|
|
289
|
+
{formatCost(row.mtdCost)}
|
|
290
|
+
</span>
|
|
291
|
+
</td>
|
|
292
|
+
|
|
293
|
+
{/* Delta */}
|
|
294
|
+
<td className="px-3 py-3 text-right">
|
|
295
|
+
<span className={clsx('text-sm font-mono font-semibold', delta.color)}>{delta.text}</span>
|
|
296
|
+
</td>
|
|
297
|
+
|
|
298
|
+
{/* Activity */}
|
|
299
|
+
<td className="px-3 py-3">
|
|
300
|
+
<div className="flex items-center gap-2">
|
|
301
|
+
<ActivityBar value={row.activity} />
|
|
302
|
+
{row.activityTrend.length > 0 && (
|
|
303
|
+
<Sparkline data={row.activityTrend} width={40} height={16} color="#3b82f6" />
|
|
304
|
+
)}
|
|
305
|
+
</div>
|
|
306
|
+
</td>
|
|
307
|
+
|
|
308
|
+
{/* Status */}
|
|
309
|
+
<td className="px-3 py-3">
|
|
310
|
+
<StatusBadge status={row.status} />
|
|
311
|
+
</td>
|
|
312
|
+
|
|
313
|
+
{/* Circuit Breaker */}
|
|
314
|
+
<td className="px-3 py-3">
|
|
315
|
+
<CircuitBreakerIndicator state={row.circuitBreaker} />
|
|
316
|
+
</td>
|
|
317
|
+
|
|
318
|
+
{/* Last Seen / Heartbeat */}
|
|
319
|
+
<td className="px-3 py-3">
|
|
320
|
+
<HeartbeatIndicator lastSeen={row.lastSeen} />
|
|
321
|
+
</td>
|
|
322
|
+
</tr>
|
|
323
|
+
|
|
324
|
+
{/* Expanded Resource Breakdown */}
|
|
325
|
+
{isExpanded && (
|
|
326
|
+
<tr className="bg-gray-50 dark:bg-slate-900/50">
|
|
327
|
+
<td colSpan={8} className="px-6 py-4">
|
|
328
|
+
<ResourceBreakdown
|
|
329
|
+
projectId={row.id}
|
|
330
|
+
breakdown={breakdown}
|
|
331
|
+
isLoading={isLoadingBreakdown}
|
|
332
|
+
/>
|
|
333
|
+
</td>
|
|
334
|
+
</tr>
|
|
335
|
+
)}
|
|
336
|
+
</>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function ProjectsTable({
|
|
341
|
+
rows,
|
|
342
|
+
expanded,
|
|
343
|
+
onExpand,
|
|
344
|
+
onSort,
|
|
345
|
+
sort,
|
|
346
|
+
sortDir,
|
|
347
|
+
resourceCache,
|
|
348
|
+
}: ProjectsTableProps) {
|
|
349
|
+
const handleToggle = useCallback(
|
|
350
|
+
(projectId: string) => {
|
|
351
|
+
onExpand(projectId);
|
|
352
|
+
},
|
|
353
|
+
[onExpand]
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
// Determine which rows are loading their breakdown
|
|
357
|
+
const loadingRows = useMemo(() => {
|
|
358
|
+
const loading = new Set<string>();
|
|
359
|
+
for (const id of expanded) {
|
|
360
|
+
if (!resourceCache.has(id)) {
|
|
361
|
+
loading.add(id);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return loading;
|
|
365
|
+
}, [expanded, resourceCache]);
|
|
366
|
+
|
|
367
|
+
if (rows.length === 0) {
|
|
368
|
+
return (
|
|
369
|
+
<div className="bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-sm p-8 text-center">
|
|
370
|
+
<p className="text-gray-500 dark:text-slate-500 text-sm">No projects found</p>
|
|
371
|
+
</div>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return (
|
|
376
|
+
<div className="bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-sm overflow-hidden">
|
|
377
|
+
<table className="w-full">
|
|
378
|
+
<thead>
|
|
379
|
+
<tr className="border-b border-gray-300 dark:border-slate-700 bg-gray-100/50 dark:bg-slate-800/50">
|
|
380
|
+
<th className="px-3 py-2 w-10" /> {/* Chevron column */}
|
|
381
|
+
<th className="px-3 py-2 text-left">
|
|
382
|
+
<SortHeader
|
|
383
|
+
field="name"
|
|
384
|
+
label="Project"
|
|
385
|
+
currentSort={sort}
|
|
386
|
+
sortDir={sortDir}
|
|
387
|
+
onSort={onSort}
|
|
388
|
+
/>
|
|
389
|
+
</th>
|
|
390
|
+
<th className="px-3 py-2 text-right">
|
|
391
|
+
<SortHeader
|
|
392
|
+
field="cost"
|
|
393
|
+
label="MTD Cost"
|
|
394
|
+
currentSort={sort}
|
|
395
|
+
sortDir={sortDir}
|
|
396
|
+
onSort={onSort}
|
|
397
|
+
align="right"
|
|
398
|
+
/>
|
|
399
|
+
</th>
|
|
400
|
+
<th className="px-3 py-2 text-right">
|
|
401
|
+
<span className="text-xs font-mono text-gray-500 dark:text-slate-500 uppercase tracking-wider">
|
|
402
|
+
Delta
|
|
403
|
+
</span>
|
|
404
|
+
</th>
|
|
405
|
+
<th className="px-3 py-2 text-left">
|
|
406
|
+
<SortHeader
|
|
407
|
+
field="activity"
|
|
408
|
+
label="Activity"
|
|
409
|
+
currentSort={sort}
|
|
410
|
+
sortDir={sortDir}
|
|
411
|
+
onSort={onSort}
|
|
412
|
+
/>
|
|
413
|
+
</th>
|
|
414
|
+
<th className="px-3 py-2 text-left">
|
|
415
|
+
<span className="text-xs font-mono text-gray-500 dark:text-slate-500 uppercase tracking-wider">
|
|
416
|
+
Status
|
|
417
|
+
</span>
|
|
418
|
+
</th>
|
|
419
|
+
<th className="px-3 py-2 text-left">
|
|
420
|
+
<span className="text-xs font-mono text-gray-500 dark:text-slate-500 uppercase tracking-wider">
|
|
421
|
+
CB
|
|
422
|
+
</span>
|
|
423
|
+
</th>
|
|
424
|
+
<th className="px-3 py-2 text-left">
|
|
425
|
+
<span className="text-xs font-mono text-gray-500 dark:text-slate-500 uppercase tracking-wider">
|
|
426
|
+
Last Seen
|
|
427
|
+
</span>
|
|
428
|
+
</th>
|
|
429
|
+
</tr>
|
|
430
|
+
</thead>
|
|
431
|
+
<tbody>
|
|
432
|
+
{rows.map((row) => (
|
|
433
|
+
<ProjectRow
|
|
434
|
+
key={row.id}
|
|
435
|
+
row={row}
|
|
436
|
+
isExpanded={expanded.has(row.id)}
|
|
437
|
+
onToggle={() => handleToggle(row.id)}
|
|
438
|
+
breakdown={resourceCache.get(row.id) || null}
|
|
439
|
+
isLoadingBreakdown={loadingRows.has(row.id)}
|
|
440
|
+
/>
|
|
441
|
+
))}
|
|
442
|
+
</tbody>
|
|
443
|
+
</table>
|
|
444
|
+
</div>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export default ProjectsTable;
|