@littlebearapps/platform-admin-sdk 1.5.0 → 2.0.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.
Files changed (86) hide show
  1. package/dist/templates.d.ts +1 -1
  2. package/dist/templates.js +112 -1
  3. package/package.json +1 -1
  4. package/templates/full/dashboard/src/components/patterns/ActivePatterns.tsx +62 -0
  5. package/templates/full/dashboard/src/components/patterns/PatternTabs.tsx +116 -0
  6. package/templates/full/dashboard/src/components/patterns/SystemPatterns.tsx +52 -0
  7. package/templates/full/dashboard/src/components/patterns/index.ts +3 -0
  8. package/templates/full/dashboard/src/components/reports/GapDetectionReport.tsx +69 -0
  9. package/templates/full/dashboard/src/components/reports/SdkAuditReport.tsx +72 -0
  10. package/templates/full/dashboard/src/components/reports/index.ts +2 -0
  11. package/templates/full/dashboard/src/pages/api/notifications/[id]/read.ts +37 -0
  12. package/templates/full/dashboard/src/pages/api/notifications/read-all.ts +28 -0
  13. package/templates/full/dashboard/src/pages/api/patterns/cache-refresh.ts +38 -0
  14. package/templates/full/dashboard/src/pages/api/patterns/discover.ts +36 -0
  15. package/templates/full/dashboard/src/pages/api/patterns/ready-for-review.ts +39 -0
  16. package/templates/full/dashboard/src/pages/api/patterns/stats.ts +39 -0
  17. package/templates/full/dashboard/src/pages/api/patterns/suggestions.ts +43 -0
  18. package/templates/full/dashboard/src/pages/api/reports/audit.ts +45 -0
  19. package/templates/full/dashboard/src/pages/api/reports/usage.ts +52 -0
  20. package/templates/full/dashboard/src/pages/api/search/reindex.ts +28 -0
  21. package/templates/full/dashboard/src/pages/api/search/stats.ts +27 -0
  22. package/templates/full/dashboard/src/pages/api/settings/index.ts +37 -0
  23. package/templates/full/dashboard/src/pages/api/settings/update.ts +41 -0
  24. package/templates/full/dashboard/src/pages/api/topology/index.ts +56 -0
  25. package/templates/full/scripts/ops/universal-backfill.ts +147 -0
  26. package/templates/shared/.github/workflows/contract-check.yml.hbs +42 -0
  27. package/templates/shared/.github/workflows/dashboard-deploy.yml.hbs +39 -0
  28. package/templates/shared/.github/workflows/security.yml +33 -0
  29. package/templates/shared/dashboard/src/components/Nav.astro.hbs +2 -0
  30. package/templates/shared/dashboard/src/components/infrastructure/AlertHistory.tsx +57 -0
  31. package/templates/shared/dashboard/src/components/infrastructure/InfrastructureStats.tsx +73 -0
  32. package/templates/shared/dashboard/src/components/infrastructure/ServiceRegistry.tsx +55 -0
  33. package/templates/shared/dashboard/src/components/infrastructure/UptimeStatus.tsx +56 -0
  34. package/templates/shared/dashboard/src/components/infrastructure/index.ts +4 -0
  35. package/templates/shared/dashboard/src/components/ui/Breadcrumbs.tsx +27 -0
  36. package/templates/shared/dashboard/src/components/ui/EmptyState.tsx +26 -0
  37. package/templates/shared/dashboard/src/components/ui/ErrorBoundary.tsx +42 -0
  38. package/templates/shared/dashboard/src/components/ui/LoadingSkeleton.tsx +18 -0
  39. package/templates/shared/dashboard/src/components/ui/PageShell.tsx +26 -0
  40. package/templates/shared/dashboard/src/components/ui/Toast.tsx +44 -0
  41. package/templates/shared/dashboard/src/components/ui/index.ts +6 -0
  42. package/templates/shared/dashboard/src/components/usage/AnomaliesWidget.tsx +68 -0
  43. package/templates/shared/dashboard/src/components/usage/HourlyUsageChart.tsx +55 -0
  44. package/templates/shared/dashboard/src/components/usage/PlanAllowanceDashboard.tsx +67 -0
  45. package/templates/shared/dashboard/src/components/usage/ProjectCostBreakdown.tsx +55 -0
  46. package/templates/shared/dashboard/src/components/usage/index.ts +4 -0
  47. package/templates/shared/dashboard/src/lib/cloudflare/costs.ts +21 -0
  48. package/templates/shared/dashboard/src/pages/api/costs/overview.ts +65 -0
  49. package/templates/shared/dashboard/src/pages/api/costs/providers.ts +47 -0
  50. package/templates/shared/dashboard/src/pages/api/infrastructure/services.ts +55 -0
  51. package/templates/shared/dashboard/src/pages/api/infrastructure/stats.ts +99 -0
  52. package/templates/shared/dashboard/src/pages/api/usage/allowances.ts +56 -0
  53. package/templates/shared/dashboard/src/pages/api/usage/anomalies.ts +45 -0
  54. package/templates/shared/dashboard/src/pages/api/usage/billing.ts +53 -0
  55. package/templates/shared/dashboard/src/pages/api/usage/granular.ts +50 -0
  56. package/templates/shared/dashboard/src/pages/api/usage/hourly.ts +45 -0
  57. package/templates/shared/dashboard/src/pages/api/usage/projects.ts +51 -0
  58. package/templates/shared/dashboard/src/pages/api/user/identity.ts +11 -0
  59. package/templates/shared/dashboard/src/pages/settings/notifications.astro +34 -0
  60. package/templates/shared/dashboard/src/pages/settings/thresholds.astro +39 -0
  61. package/templates/shared/dashboard/src/pages/settings/usage.astro +28 -0
  62. package/templates/shared/docs/architecture.md +89 -0
  63. package/templates/shared/docs/post-deploy-runbook.md +126 -0
  64. package/templates/shared/docs/troubleshooting.md +91 -0
  65. package/templates/shared/package.json.hbs +5 -0
  66. package/templates/shared/scripts/ops/backfill-cloudflare-daily.ts +145 -0
  67. package/templates/shared/scripts/ops/backfill-monthly-rollups.ts +125 -0
  68. package/templates/shared/scripts/ops/validate-controls.js +141 -0
  69. package/templates/shared/tests/contract/validate-schemas.test.ts +130 -0
  70. package/templates/shared/tests/fixtures/telemetry-envelope-invalid.json +9 -0
  71. package/templates/shared/tests/fixtures/telemetry-envelope-valid.json +27 -0
  72. package/templates/shared/tests/helpers/mock-d1.ts +61 -0
  73. package/templates/shared/tests/helpers/mock-kv.ts +37 -0
  74. package/templates/shared/tests/unit/workers/batch-persistence.test.ts +133 -0
  75. package/templates/shared/tests/unit/workers/budget-enforcement.test.ts +214 -0
  76. package/templates/shared/vitest.config.ts +18 -0
  77. package/templates/standard/dashboard/src/components/health/CircuitBreakerEvents.tsx +69 -0
  78. package/templates/standard/dashboard/src/components/health/CircuitBreakerPanel.tsx +97 -0
  79. package/templates/standard/dashboard/src/components/health/index.ts +2 -0
  80. package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/mute.ts +49 -0
  81. package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/resolve.ts +36 -0
  82. package/templates/standard/dashboard/src/pages/api/errors/[fingerprint].ts +55 -0
  83. package/templates/standard/dashboard/src/pages/api/health/audit-history.ts +37 -0
  84. package/templates/standard/dashboard/src/pages/circuit-breakers.astro +13 -0
  85. package/templates/standard/tests/unit/error-collector/capture.test.ts +106 -0
  86. package/templates/standard/tests/unit/error-collector/fingerprint.test.ts +155 -0
@@ -0,0 +1,39 @@
1
+ name: Deploy Dashboard
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ paths:
7
+ - 'dashboard/**'
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ deploy:
12
+ runs-on: ubuntu-latest
13
+ permissions:
14
+ contents: read
15
+ deployments: write
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - uses: actions/setup-node@v4
20
+ with:
21
+ node-version: 20
22
+ cache: npm
23
+ cache-dependency-path: dashboard/package-lock.json
24
+
25
+ - name: Install dependencies
26
+ working-directory: dashboard
27
+ run: npm ci
28
+
29
+ - name: Build
30
+ working-directory: dashboard
31
+ run: npm run build
32
+
33
+ - name: Deploy to Cloudflare Pages
34
+ uses: cloudflare/wrangler-action@v3
35
+ with:
36
+ apiToken: $\{{ secrets.CLOUDFLARE_API_TOKEN }}
37
+ accountId: $\{{ secrets.CLOUDFLARE_ACCOUNT_ID }}
38
+ command: pages deploy dist --project-name={{projectSlug}}-dashboard
39
+ workingDirectory: dashboard
@@ -0,0 +1,33 @@
1
+ # Security Scanning
2
+ #
3
+ # Runs dependency audit and secret detection on pushes and PRs.
4
+
5
+ name: Security
6
+
7
+ on:
8
+ push:
9
+ branches: [main]
10
+ pull_request:
11
+ branches: [main]
12
+
13
+ jobs:
14
+ audit:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - uses: actions/setup-node@v4
20
+ with:
21
+ node-version: '22'
22
+ cache: 'npm'
23
+
24
+ - run: npm ci
25
+
26
+ - name: Audit dependencies
27
+ run: npm audit --audit-level=high
28
+ continue-on-error: true
29
+
30
+ - name: Check for secrets
31
+ uses: trufflesecurity/trufflehog@main
32
+ with:
33
+ extra_args: --only-verified
@@ -13,6 +13,7 @@ const navItems: NavItem[] = [
13
13
  {{#if isStandard}}
14
14
  { href: '/health', label: 'Health', icon: 'activity' },
15
15
  { href: '/errors', label: 'Errors', icon: 'alert-triangle' },
16
+ { href: '/circuit-breakers', label: 'Breakers', icon: 'zap' },
16
17
  {{/if}}
17
18
  {{#if isFull}}
18
19
  { href: '/notifications', label: 'Notifications', icon: 'bell' },
@@ -47,6 +48,7 @@ function isActive(href: string): boolean {
47
48
  {item.icon === 'server' && '\u229E'}
48
49
  {item.icon === 'activity' && '\u2661'}
49
50
  {item.icon === 'alert-triangle' && '\u25B3'}
51
+ {item.icon === 'zap' && '\u26A1'}
50
52
  {item.icon === 'bell' && '\uD83D\uDD14'}
51
53
  {item.icon === 'settings' && '\u2699'}
52
54
  </span>
@@ -0,0 +1,57 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { LoadingSkeleton } from '../ui/LoadingSkeleton';
3
+ import { EmptyState } from '../ui/EmptyState';
4
+
5
+ interface Alert {
6
+ id: number;
7
+ title: string;
8
+ severity: string;
9
+ source: string;
10
+ created_at: string;
11
+ resolved_at?: string;
12
+ }
13
+
14
+ export function AlertHistory() {
15
+ const [alerts, setAlerts] = useState<Alert[]>([]);
16
+ const [loading, setLoading] = useState(true);
17
+
18
+ useEffect(() => {
19
+ fetch('/api/usage/anomalies?limit=20')
20
+ .then((r) => r.json())
21
+ .then((data: { anomalies: Alert[] }) => {
22
+ setAlerts(data.anomalies ?? []);
23
+ setLoading(false);
24
+ })
25
+ .catch(() => setLoading(false));
26
+ }, []);
27
+
28
+ if (loading) return <LoadingSkeleton lines={3} />;
29
+ if (alerts.length === 0) return <EmptyState title="No alerts" description="No recent alerts or anomalies detected." />;
30
+
31
+ const severityColour: Record<string, string> = {
32
+ critical: 'text-red-600 dark:text-red-400',
33
+ warning: 'text-yellow-600 dark:text-yellow-400',
34
+ info: 'text-blue-600 dark:text-blue-400',
35
+ };
36
+
37
+ return (
38
+ <div className="space-y-2">
39
+ {alerts.map((a) => (
40
+ <div
41
+ key={a.id}
42
+ className="flex items-start justify-between gap-3 py-2 px-3 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
43
+ >
44
+ <div className="min-w-0">
45
+ <p className={`text-sm font-medium ${severityColour[a.severity] ?? 'text-gray-900 dark:text-white'}`}>
46
+ {a.title}
47
+ </p>
48
+ <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{a.source}</p>
49
+ </div>
50
+ <span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
51
+ {new Date(a.created_at).toLocaleDateString()}
52
+ </span>
53
+ </div>
54
+ ))}
55
+ </div>
56
+ );
57
+ }
@@ -0,0 +1,73 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { LoadingSkeleton } from '../ui/LoadingSkeleton';
3
+
4
+ interface Stats {
5
+ services: {
6
+ total: number;
7
+ byStatus: Record<string, number>;
8
+ byType: Record<string, number>;
9
+ byProject: Record<string, number>;
10
+ };
11
+ alerts: {
12
+ total: number;
13
+ unacknowledged: number;
14
+ critical: number;
15
+ };
16
+ }
17
+
18
+ export function InfrastructureStats() {
19
+ const [data, setData] = useState<Stats | null>(null);
20
+ const [loading, setLoading] = useState(true);
21
+
22
+ useEffect(() => {
23
+ fetch('/api/infrastructure/stats')
24
+ .then(res => res.json())
25
+ .then((stats: Stats) => { setData(stats); setLoading(false); })
26
+ .catch(() => setLoading(false));
27
+ }, []);
28
+
29
+ if (loading) return <LoadingSkeleton lines={4} />;
30
+
31
+ if (!data) {
32
+ return <p className="text-sm text-gray-500 dark:text-gray-400">No infrastructure data available.</p>;
33
+ }
34
+
35
+ const cards = [
36
+ { label: 'Total Services', value: data.services.total, icon: '\u229E' },
37
+ { label: 'Deployed', value: data.services.byStatus.deployed ?? 0, icon: '\u2713' },
38
+ { label: 'Alerts', value: data.alerts.total, icon: '\u25B3' },
39
+ { label: 'Critical', value: data.alerts.critical, icon: '\u26A0' },
40
+ ];
41
+
42
+ return (
43
+ <div className="space-y-4">
44
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
45
+ {cards.map(card => (
46
+ <div key={card.label} className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
47
+ <div className="flex items-center gap-2 mb-1">
48
+ <span className="opacity-50">{card.icon}</span>
49
+ <span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">{card.label}</span>
50
+ </div>
51
+ <p className="text-2xl font-bold text-gray-900 dark:text-white">{card.value}</p>
52
+ </div>
53
+ ))}
54
+ </div>
55
+
56
+ {Object.keys(data.services.byType).length > 0 && (
57
+ <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
58
+ <h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
59
+ By Resource Type
60
+ </h3>
61
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-2">
62
+ {Object.entries(data.services.byType).map(([type, count]) => (
63
+ <div key={type} className="flex items-center justify-between py-1">
64
+ <span className="text-sm text-gray-600 dark:text-gray-300">{type}</span>
65
+ <span className="text-sm font-medium text-gray-900 dark:text-white">{count}</span>
66
+ </div>
67
+ ))}
68
+ </div>
69
+ </div>
70
+ )}
71
+ </div>
72
+ );
73
+ }
@@ -0,0 +1,55 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { EmptyState } from '../ui/EmptyState';
3
+ import { LoadingSkeleton } from '../ui/LoadingSkeleton';
4
+
5
+ interface Service {
6
+ script_name: string;
7
+ project: string;
8
+ resource_type: string;
9
+ last_seen_at?: string;
10
+ }
11
+
12
+ export function ServiceRegistry() {
13
+ const [services, setServices] = useState<Service[]>([]);
14
+ const [loading, setLoading] = useState(true);
15
+
16
+ useEffect(() => {
17
+ fetch('/api/infrastructure/services')
18
+ .then((r) => r.json())
19
+ .then((data: { services: Service[] }) => {
20
+ setServices(data.services ?? []);
21
+ setLoading(false);
22
+ })
23
+ .catch(() => setLoading(false));
24
+ }, []);
25
+
26
+ if (loading) return <LoadingSkeleton lines={5} />;
27
+ if (services.length === 0) return <EmptyState title="No services" description="Register services via services.yaml." />;
28
+
29
+ const grouped = services.reduce<Record<string, Service[]>>((acc, s) => {
30
+ const key = s.project || 'unassigned';
31
+ (acc[key] ??= []).push(s);
32
+ return acc;
33
+ }, {});
34
+
35
+ return (
36
+ <div className="space-y-4">
37
+ {Object.entries(grouped).map(([project, svcList]) => (
38
+ <div key={project}>
39
+ <h4 className="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400 mb-1">{project}</h4>
40
+ <div className="space-y-1">
41
+ {svcList.map((s) => (
42
+ <div
43
+ key={s.script_name}
44
+ className="flex items-center justify-between py-1 px-2 text-sm bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
45
+ >
46
+ <span className="font-mono text-gray-900 dark:text-white">{s.script_name}</span>
47
+ <span className="text-xs text-gray-400 dark:text-gray-500">{s.resource_type}</span>
48
+ </div>
49
+ ))}
50
+ </div>
51
+ </div>
52
+ ))}
53
+ </div>
54
+ );
55
+ }
@@ -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,4 @@
1
+ export { InfrastructureStats } from './InfrastructureStats';
2
+ export { UptimeStatus } from './UptimeStatus';
3
+ export { ServiceRegistry } from './ServiceRegistry';
4
+ export { AlertHistory } from './AlertHistory';
@@ -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
+ }