@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,56 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { LoadingSkeleton } from '../ui/LoadingSkeleton';
|
|
3
|
+
|
|
4
|
+
interface ServiceStatus {
|
|
5
|
+
name: string;
|
|
6
|
+
status: 'healthy' | 'degraded' | 'down' | 'unknown';
|
|
7
|
+
lastCheck: string;
|
|
8
|
+
uptimePct?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function UptimeStatus() {
|
|
12
|
+
const [services, setServices] = useState<ServiceStatus[]>([]);
|
|
13
|
+
const [loading, setLoading] = useState(true);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
fetch('/api/infrastructure/services')
|
|
17
|
+
.then((r) => r.json())
|
|
18
|
+
.then((data: { services: ServiceStatus[] }) => {
|
|
19
|
+
setServices(data.services ?? []);
|
|
20
|
+
setLoading(false);
|
|
21
|
+
})
|
|
22
|
+
.catch(() => setLoading(false));
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
if (loading) return <LoadingSkeleton lines={3} />;
|
|
26
|
+
|
|
27
|
+
const statusColour: Record<string, string> = {
|
|
28
|
+
healthy: 'bg-green-500',
|
|
29
|
+
degraded: 'bg-yellow-500',
|
|
30
|
+
down: 'bg-red-500',
|
|
31
|
+
unknown: 'bg-gray-400',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="space-y-2">
|
|
36
|
+
{services.length === 0 ? (
|
|
37
|
+
<p className="text-sm text-gray-500 dark:text-gray-400">No service data available.</p>
|
|
38
|
+
) : (
|
|
39
|
+
services.map((s) => (
|
|
40
|
+
<div
|
|
41
|
+
key={s.name}
|
|
42
|
+
className="flex items-center justify-between py-1.5 px-3 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
|
|
43
|
+
>
|
|
44
|
+
<div className="flex items-center gap-2">
|
|
45
|
+
<span className={`w-2 h-2 rounded-full ${statusColour[s.status] ?? statusColour.unknown}`} />
|
|
46
|
+
<span className="text-sm text-gray-900 dark:text-white">{s.name}</span>
|
|
47
|
+
</div>
|
|
48
|
+
{s.uptimePct !== undefined && (
|
|
49
|
+
<span className="text-xs text-gray-500 dark:text-gray-400">{s.uptimePct.toFixed(1)}%</span>
|
|
50
|
+
)}
|
|
51
|
+
</div>
|
|
52
|
+
))
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReportInfoButton Component
|
|
3
|
+
* Reusable info icon button that toggles a floating panel with report explanation.
|
|
4
|
+
* Based on PatternInfoButton pattern.
|
|
5
|
+
*/
|
|
6
|
+
import { useState, useRef, useEffect, type ReactNode } from 'react';
|
|
7
|
+
|
|
8
|
+
interface ReportInfoButtonProps {
|
|
9
|
+
title: string;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
/** Colour theme: matches the original info panel colours per report */
|
|
12
|
+
theme?: 'blue' | 'yellow' | 'orange' | 'red' | 'purple' | 'green' | 'teal';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const themeClasses: Record<string, { button: string; panel: string; heading: string }> = {
|
|
16
|
+
blue: {
|
|
17
|
+
button: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900/40 dark:hover:bg-blue-900/60 text-blue-600 dark:text-blue-400',
|
|
18
|
+
panel: 'bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800',
|
|
19
|
+
heading: 'text-blue-800 dark:text-blue-300',
|
|
20
|
+
},
|
|
21
|
+
yellow: {
|
|
22
|
+
button: 'bg-yellow-100 hover:bg-yellow-200 dark:bg-yellow-900/40 dark:hover:bg-yellow-900/60 text-yellow-600 dark:text-yellow-400',
|
|
23
|
+
panel: 'bg-yellow-50 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800',
|
|
24
|
+
heading: 'text-yellow-800 dark:text-yellow-300',
|
|
25
|
+
},
|
|
26
|
+
orange: {
|
|
27
|
+
button: 'bg-orange-100 hover:bg-orange-200 dark:bg-orange-900/40 dark:hover:bg-orange-900/60 text-orange-600 dark:text-orange-400',
|
|
28
|
+
panel: 'bg-orange-50 dark:bg-orange-900/30 border-orange-200 dark:border-orange-800',
|
|
29
|
+
heading: 'text-orange-800 dark:text-orange-300',
|
|
30
|
+
},
|
|
31
|
+
red: {
|
|
32
|
+
button: 'bg-red-100 hover:bg-red-200 dark:bg-red-900/40 dark:hover:bg-red-900/60 text-red-600 dark:text-red-400',
|
|
33
|
+
panel: 'bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800',
|
|
34
|
+
heading: 'text-red-800 dark:text-red-300',
|
|
35
|
+
},
|
|
36
|
+
purple: {
|
|
37
|
+
button: 'bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/40 dark:hover:bg-purple-900/60 text-purple-600 dark:text-purple-400',
|
|
38
|
+
panel: 'bg-purple-50 dark:bg-purple-900/30 border-purple-200 dark:border-purple-800',
|
|
39
|
+
heading: 'text-purple-800 dark:text-purple-300',
|
|
40
|
+
},
|
|
41
|
+
green: {
|
|
42
|
+
button: 'bg-green-100 hover:bg-green-200 dark:bg-green-900/40 dark:hover:bg-green-900/60 text-green-600 dark:text-green-400',
|
|
43
|
+
panel: 'bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800',
|
|
44
|
+
heading: 'text-green-800 dark:text-green-300',
|
|
45
|
+
},
|
|
46
|
+
teal: {
|
|
47
|
+
button: 'bg-teal-100 hover:bg-teal-200 dark:bg-teal-900/40 dark:hover:bg-teal-900/60 text-teal-600 dark:text-teal-400',
|
|
48
|
+
panel: 'bg-teal-50 dark:bg-teal-900/30 border-teal-200 dark:border-teal-800',
|
|
49
|
+
heading: 'text-teal-800 dark:text-teal-300',
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function ReportInfoButton({ title, children, theme = 'blue' }: ReportInfoButtonProps) {
|
|
54
|
+
const [open, setOpen] = useState(false);
|
|
55
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
56
|
+
const colours = themeClasses[theme];
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!open) return;
|
|
60
|
+
|
|
61
|
+
function handleClickOutside(event: MouseEvent) {
|
|
62
|
+
if (panelRef.current && !panelRef.current.contains(event.target as Node)) {
|
|
63
|
+
setOpen(false);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
68
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
69
|
+
}, [open]);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="relative" ref={panelRef}>
|
|
73
|
+
<button
|
|
74
|
+
onClick={() => setOpen(!open)}
|
|
75
|
+
className={`inline-flex items-center justify-center w-9 h-9 rounded-full ${colours.button} transition-colors`}
|
|
76
|
+
title={title}
|
|
77
|
+
aria-label={title}
|
|
78
|
+
>
|
|
79
|
+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
80
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
81
|
+
</svg>
|
|
82
|
+
</button>
|
|
83
|
+
|
|
84
|
+
{open && (
|
|
85
|
+
<div className={`absolute right-0 top-12 z-50 w-96 border rounded-lg p-4 shadow-lg ${colours.panel}`}>
|
|
86
|
+
<h3 className={`font-semibold ${colours.heading} mb-2`}>{title}</h3>
|
|
87
|
+
{children}
|
|
88
|
+
<button
|
|
89
|
+
onClick={() => setOpen(false)}
|
|
90
|
+
className={`mt-3 text-xs ${colours.heading} hover:underline`}
|
|
91
|
+
>
|
|
92
|
+
Close
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
interface Crumb {
|
|
2
|
+
label: string;
|
|
3
|
+
href?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
items: Crumb[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Breadcrumbs({ items }: Props) {
|
|
11
|
+
return (
|
|
12
|
+
<nav className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 mb-4">
|
|
13
|
+
{items.map((item, i) => (
|
|
14
|
+
<span key={i} className="flex items-center gap-1.5">
|
|
15
|
+
{i > 0 && <span className="opacity-40">/</span>}
|
|
16
|
+
{item.href ? (
|
|
17
|
+
<a href={item.href} className="hover:text-gray-900 dark:hover:text-white transition-colors">
|
|
18
|
+
{item.label}
|
|
19
|
+
</a>
|
|
20
|
+
) : (
|
|
21
|
+
<span className="text-gray-900 dark:text-white font-medium">{item.label}</span>
|
|
22
|
+
)}
|
|
23
|
+
</span>
|
|
24
|
+
))}
|
|
25
|
+
</nav>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
icon?: string;
|
|
3
|
+
title: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
action?: { label: string; href: string };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function EmptyState({ icon = '\u2205', title, description, action }: Props) {
|
|
9
|
+
return (
|
|
10
|
+
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
11
|
+
<span className="text-4xl mb-3 opacity-40">{icon}</span>
|
|
12
|
+
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{title}</h3>
|
|
13
|
+
{description && (
|
|
14
|
+
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400 max-w-sm">{description}</p>
|
|
15
|
+
)}
|
|
16
|
+
{action && (
|
|
17
|
+
<a
|
|
18
|
+
href={action.href}
|
|
19
|
+
className="mt-4 text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline"
|
|
20
|
+
>
|
|
21
|
+
{action.label}
|
|
22
|
+
</a>
|
|
23
|
+
)}
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Component } from 'react';
|
|
2
|
+
import type { ReactNode, ErrorInfo } from 'react';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
fallback?: ReactNode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface State {
|
|
10
|
+
hasError: boolean;
|
|
11
|
+
error?: Error;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class ErrorBoundary extends Component<Props, State> {
|
|
15
|
+
constructor(props: Props) {
|
|
16
|
+
super(props);
|
|
17
|
+
this.state = { hasError: false };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static getDerivedStateFromError(error: Error): State {
|
|
21
|
+
return { hasError: true, error };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
|
25
|
+
console.error('[ErrorBoundary]', error, errorInfo);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
render(): ReactNode {
|
|
29
|
+
if (this.state.hasError) {
|
|
30
|
+
return (
|
|
31
|
+
this.props.fallback ?? (
|
|
32
|
+
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
|
33
|
+
<p className="text-sm font-medium text-red-800 dark:text-red-300">Something went wrong</p>
|
|
34
|
+
<p className="text-xs text-red-600 dark:text-red-400 mt-1">{this.state.error?.message}</p>
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return this.props.children;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
lines?: number;
|
|
3
|
+
className?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function LoadingSkeleton({ lines = 3, className = '' }: Props) {
|
|
7
|
+
return (
|
|
8
|
+
<div className={`animate-pulse space-y-3 ${className}`}>
|
|
9
|
+
{Array.from({ length: lines }).map((_, i) => (
|
|
10
|
+
<div
|
|
11
|
+
key={i}
|
|
12
|
+
className="h-4 bg-gray-200 dark:bg-gray-700 rounded"
|
|
13
|
+
style={{ width: `${85 - i * 15}%` }}
|
|
14
|
+
/>
|
|
15
|
+
))}
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { Breadcrumbs } from './Breadcrumbs';
|
|
3
|
+
|
|
4
|
+
interface PageShellProps {
|
|
5
|
+
title: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
breadcrumbs?: Array<{ label: string; href?: string }>;
|
|
8
|
+
actions?: ReactNode;
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function PageShell({ title, description, breadcrumbs, actions, children }: PageShellProps) {
|
|
13
|
+
return (
|
|
14
|
+
<div className="space-y-4">
|
|
15
|
+
{breadcrumbs && breadcrumbs.length > 0 && <Breadcrumbs items={breadcrumbs} />}
|
|
16
|
+
<div className="flex items-start justify-between gap-4">
|
|
17
|
+
<div>
|
|
18
|
+
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">{title}</h1>
|
|
19
|
+
{description && <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{description}</p>}
|
|
20
|
+
</div>
|
|
21
|
+
{actions && <div className="shrink-0">{actions}</div>}
|
|
22
|
+
</div>
|
|
23
|
+
{children}
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
interface Props {
|
|
4
|
+
type: 'success' | 'error' | 'info';
|
|
5
|
+
message: string;
|
|
6
|
+
duration?: number;
|
|
7
|
+
onDismiss?: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const styles: Record<Props['type'], string> = {
|
|
11
|
+
success: 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 text-green-800 dark:text-green-200',
|
|
12
|
+
error: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200',
|
|
13
|
+
info: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function Toast({ type, message, duration = 5000, onDismiss }: Props) {
|
|
17
|
+
const [visible, setVisible] = useState(true);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (duration <= 0) return;
|
|
21
|
+
const timer = setTimeout(() => {
|
|
22
|
+
setVisible(false);
|
|
23
|
+
onDismiss?.();
|
|
24
|
+
}, duration);
|
|
25
|
+
return () => clearTimeout(timer);
|
|
26
|
+
}, [duration, onDismiss]);
|
|
27
|
+
|
|
28
|
+
if (!visible) return null;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className={`fixed bottom-4 right-4 z-50 border rounded-lg px-4 py-3 shadow-lg max-w-sm ${styles[type]}`}>
|
|
32
|
+
<div className="flex items-center justify-between gap-3">
|
|
33
|
+
<p className="text-sm font-medium">{message}</p>
|
|
34
|
+
<button
|
|
35
|
+
onClick={() => { setVisible(false); onDismiss?.(); }}
|
|
36
|
+
className="text-current opacity-50 hover:opacity-100 shrink-0"
|
|
37
|
+
aria-label="Dismiss"
|
|
38
|
+
>
|
|
39
|
+
x
|
|
40
|
+
</button>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
export { Sparkline } from './Sparkline';
|
|
2
2
|
export { StatusDot } from './StatusDot';
|
|
3
3
|
export { AlertBanner } from './AlertBanner';
|
|
4
|
+
export { EmptyState } from './EmptyState';
|
|
5
|
+
export { LoadingSkeleton } from './LoadingSkeleton';
|
|
6
|
+
export { Breadcrumbs } from './Breadcrumbs';
|
|
7
|
+
export { Toast } from './Toast';
|
|
8
|
+
export { ErrorBoundary } from './ErrorBoundary';
|
|
9
|
+
export { PageShell } from './PageShell';
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { EmptyState } from '../ui/EmptyState';
|
|
3
|
+
import { LoadingSkeleton } from '../ui/LoadingSkeleton';
|
|
4
|
+
|
|
5
|
+
interface Anomaly {
|
|
6
|
+
id: number;
|
|
7
|
+
project: string;
|
|
8
|
+
metric: string;
|
|
9
|
+
current_value: number;
|
|
10
|
+
baseline_value: number;
|
|
11
|
+
deviation_pct: number;
|
|
12
|
+
detected_at: string;
|
|
13
|
+
status: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function AnomaliesWidget() {
|
|
17
|
+
const [anomalies, setAnomalies] = useState<Anomaly[]>([]);
|
|
18
|
+
const [loading, setLoading] = useState(true);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
fetch('/api/usage/anomalies')
|
|
22
|
+
.then(res => res.json())
|
|
23
|
+
.then((data: { anomalies: Anomaly[] }) => { setAnomalies(data.anomalies); setLoading(false); })
|
|
24
|
+
.catch(() => setLoading(false));
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
if (loading) return <LoadingSkeleton lines={3} />;
|
|
28
|
+
|
|
29
|
+
if (anomalies.length === 0) {
|
|
30
|
+
return (
|
|
31
|
+
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
32
|
+
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
|
|
33
|
+
Anomalies
|
|
34
|
+
</h3>
|
|
35
|
+
<EmptyState title="No anomalies detected" description="Usage patterns look normal." />
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
42
|
+
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
|
|
43
|
+
Recent Anomalies
|
|
44
|
+
</h3>
|
|
45
|
+
<div className="space-y-2">
|
|
46
|
+
{anomalies.map(a => (
|
|
47
|
+
<div key={a.id} className="flex items-center justify-between py-1.5 border-b border-gray-100 dark:border-gray-700 last:border-0">
|
|
48
|
+
<div>
|
|
49
|
+
<p className="text-sm text-gray-900 dark:text-white">
|
|
50
|
+
{a.metric.replace(/_/g, ' ')} — {a.project}
|
|
51
|
+
</p>
|
|
52
|
+
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
53
|
+
{new Date(a.detected_at).toLocaleDateString()}
|
|
54
|
+
</p>
|
|
55
|
+
</div>
|
|
56
|
+
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
|
57
|
+
a.deviation_pct > 200
|
|
58
|
+
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
|
59
|
+
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
|
60
|
+
}`}>
|
|
61
|
+
+{a.deviation_pct}%
|
|
62
|
+
</span>
|
|
63
|
+
</div>
|
|
64
|
+
))}
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { Sparkline } from '../ui/Sparkline';
|
|
3
|
+
import { LoadingSkeleton } from '../ui/LoadingSkeleton';
|
|
4
|
+
|
|
5
|
+
interface Snapshot {
|
|
6
|
+
snapshot_hour: string;
|
|
7
|
+
d1_reads: number;
|
|
8
|
+
d1_writes: number;
|
|
9
|
+
kv_reads: number;
|
|
10
|
+
kv_writes: number;
|
|
11
|
+
total_cost_usd: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
hours?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function HourlyUsageChart({ hours = 24 }: Props) {
|
|
19
|
+
const [snapshots, setSnapshots] = useState<Snapshot[]>([]);
|
|
20
|
+
const [loading, setLoading] = useState(true);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
fetch(`/api/usage/hourly?hours=${hours}`)
|
|
24
|
+
.then(res => res.json())
|
|
25
|
+
.then((data: { snapshots: Snapshot[] }) => { setSnapshots(data.snapshots); setLoading(false); })
|
|
26
|
+
.catch(() => setLoading(false));
|
|
27
|
+
}, [hours]);
|
|
28
|
+
|
|
29
|
+
if (loading) return <LoadingSkeleton lines={3} />;
|
|
30
|
+
|
|
31
|
+
const costData = snapshots.map(s => s.total_cost_usd);
|
|
32
|
+
const d1WriteData = snapshots.map(s => s.d1_writes);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
36
|
+
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
|
|
37
|
+
Hourly Usage ({hours}h)
|
|
38
|
+
</h3>
|
|
39
|
+
{snapshots.length === 0 ? (
|
|
40
|
+
<p className="text-sm text-gray-400">No hourly data available yet.</p>
|
|
41
|
+
) : (
|
|
42
|
+
<div className="space-y-4">
|
|
43
|
+
<div>
|
|
44
|
+
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Cost (USD)</p>
|
|
45
|
+
<Sparkline data={costData} height={40} color="blue" />
|
|
46
|
+
</div>
|
|
47
|
+
<div>
|
|
48
|
+
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">D1 Writes</p>
|
|
49
|
+
<Sparkline data={d1WriteData} height={40} color="amber" />
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { LoadingSkeleton } from '../ui/LoadingSkeleton';
|
|
3
|
+
|
|
4
|
+
interface Allowance {
|
|
5
|
+
resource: string;
|
|
6
|
+
allowance: number;
|
|
7
|
+
used: number;
|
|
8
|
+
pct: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function formatNumber(n: number): string {
|
|
12
|
+
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
|
|
13
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
14
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
15
|
+
return String(n);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function pctColor(pct: number): string {
|
|
19
|
+
if (pct >= 90) return 'bg-red-500';
|
|
20
|
+
if (pct >= 75) return 'bg-orange-500';
|
|
21
|
+
if (pct >= 50) return 'bg-yellow-500';
|
|
22
|
+
return 'bg-green-500';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function PlanAllowanceDashboard() {
|
|
26
|
+
const [allowances, setAllowances] = useState<Allowance[]>([]);
|
|
27
|
+
const [loading, setLoading] = useState(true);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
fetch('/api/usage/allowances')
|
|
31
|
+
.then(res => res.json())
|
|
32
|
+
.then((data: { allowances: Allowance[] }) => { setAllowances(data.allowances); setLoading(false); })
|
|
33
|
+
.catch(() => setLoading(false));
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
if (loading) return <LoadingSkeleton lines={4} />;
|
|
37
|
+
|
|
38
|
+
if (allowances.length === 0) {
|
|
39
|
+
return <p className="text-sm text-gray-500 dark:text-gray-400">No allowance data available.</p>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
44
|
+
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
|
|
45
|
+
Plan Allowances (MTD)
|
|
46
|
+
</h3>
|
|
47
|
+
<div className="space-y-3">
|
|
48
|
+
{allowances.map(a => (
|
|
49
|
+
<div key={a.resource}>
|
|
50
|
+
<div className="flex items-center justify-between text-sm mb-1">
|
|
51
|
+
<span className="text-gray-700 dark:text-gray-300">{a.resource.replace(/_/g, ' ')}</span>
|
|
52
|
+
<span className="text-gray-500 dark:text-gray-400">
|
|
53
|
+
{formatNumber(a.used)} / {formatNumber(a.allowance)} ({a.pct}%)
|
|
54
|
+
</span>
|
|
55
|
+
</div>
|
|
56
|
+
<div className="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
|
57
|
+
<div
|
|
58
|
+
className={`h-full rounded-full transition-all ${pctColor(a.pct)}`}
|
|
59
|
+
style={{ width: `${Math.min(a.pct, 100)}%` }}
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { LoadingSkeleton } from '../ui/LoadingSkeleton';
|
|
3
|
+
import { EmptyState } from '../ui/EmptyState';
|
|
4
|
+
|
|
5
|
+
interface ProjectCost {
|
|
6
|
+
project: string;
|
|
7
|
+
total_cost_usd: number;
|
|
8
|
+
d1_writes: number;
|
|
9
|
+
worker_requests: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ProjectCostBreakdown() {
|
|
13
|
+
const [projects, setProjects] = useState<ProjectCost[]>([]);
|
|
14
|
+
const [loading, setLoading] = useState(true);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
fetch('/api/usage/projects')
|
|
18
|
+
.then((r) => r.json())
|
|
19
|
+
.then((data: { projects: ProjectCost[] }) => {
|
|
20
|
+
setProjects(data.projects ?? []);
|
|
21
|
+
setLoading(false);
|
|
22
|
+
})
|
|
23
|
+
.catch(() => setLoading(false));
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
if (loading) return <LoadingSkeleton lines={4} />;
|
|
27
|
+
if (projects.length === 0) return <EmptyState title="No project data" description="Project cost data will appear after usage collection runs." />;
|
|
28
|
+
|
|
29
|
+
const maxCost = Math.max(...projects.map((p) => p.total_cost_usd), 0.01);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="space-y-3">
|
|
33
|
+
{projects.map((p) => (
|
|
34
|
+
<div key={p.project} className="bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 p-3">
|
|
35
|
+
<div className="flex items-center justify-between mb-1">
|
|
36
|
+
<span className="text-sm font-medium text-gray-900 dark:text-white">{p.project}</span>
|
|
37
|
+
<span className="text-sm font-mono text-gray-700 dark:text-gray-300">
|
|
38
|
+
${p.total_cost_usd.toFixed(2)}
|
|
39
|
+
</span>
|
|
40
|
+
</div>
|
|
41
|
+
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
|
42
|
+
<div
|
|
43
|
+
className="bg-blue-500 h-1.5 rounded-full transition-all"
|
|
44
|
+
style={{ width: `${Math.min((p.total_cost_usd / maxCost) * 100, 100)}%` }}
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
<div className="flex gap-4 mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
48
|
+
<span>D1 writes: {p.d1_writes.toLocaleString()}</span>
|
|
49
|
+
<span>Requests: {p.worker_requests.toLocaleString()}</span>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
))}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|