@littlebearapps/platform-admin-sdk 1.4.2 → 1.5.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 (111) hide show
  1. package/dist/templates.d.ts +1 -1
  2. package/dist/templates.js +121 -2
  3. package/package.json +1 -1
  4. package/templates/full/config/audit-targets.yaml +72 -0
  5. package/templates/full/dashboard/src/components/notifications/NotificationBell.tsx +30 -0
  6. package/templates/full/dashboard/src/components/notifications/NotificationList.tsx +116 -0
  7. package/templates/full/dashboard/src/components/notifications/index.ts +2 -0
  8. package/templates/full/dashboard/src/components/patterns/PatternStats.tsx +60 -0
  9. package/templates/full/dashboard/src/components/patterns/SuggestionsQueue.tsx +115 -0
  10. package/templates/full/dashboard/src/components/patterns/index.ts +2 -0
  11. package/templates/full/dashboard/src/components/search/SearchModal.tsx +108 -0
  12. package/templates/full/dashboard/src/pages/api/notifications/index.ts +47 -0
  13. package/templates/full/dashboard/src/pages/api/notifications/unread-count.ts +31 -0
  14. package/templates/full/dashboard/src/pages/api/patterns/approve.ts +55 -0
  15. package/templates/full/dashboard/src/pages/api/patterns/index.ts +36 -0
  16. package/templates/full/dashboard/src/pages/api/patterns/reject.ts +54 -0
  17. package/templates/full/dashboard/src/pages/api/search/index.ts +74 -0
  18. package/templates/full/dashboard/src/pages/notifications.astro +11 -0
  19. package/templates/full/migrations/008_auditor.sql +99 -0
  20. package/templates/full/migrations/010_pricing_versions.sql +110 -0
  21. package/templates/full/migrations/011_multi_account.sql +51 -0
  22. package/templates/full/scripts/ops/set-kv-pricing.ts +182 -0
  23. package/templates/full/workers/lib/ai-judge-schema.ts +181 -0
  24. package/templates/full/workers/lib/auditor/comprehensive-report.ts +407 -0
  25. package/templates/full/workers/lib/auditor/feature-coverage.ts +348 -0
  26. package/templates/full/workers/lib/auditor/index.ts +9 -0
  27. package/templates/full/workers/lib/auditor/types.ts +167 -0
  28. package/templates/full/workers/platform-auditor.ts +1071 -0
  29. package/templates/full/wrangler.auditor.jsonc.hbs +75 -0
  30. package/templates/shared/.github/workflows/platform-check.yml.hbs +28 -0
  31. package/templates/shared/config/observability.yaml.hbs +276 -0
  32. package/templates/shared/contracts/schemas/envelope.v1.schema.json +64 -0
  33. package/templates/shared/contracts/schemas/error_report.v1.schema.json +65 -0
  34. package/templates/shared/contracts/types/telemetry-envelope.types.ts +139 -0
  35. package/templates/shared/dashboard/astro.config.mjs +21 -0
  36. package/templates/shared/dashboard/package.json.hbs +29 -0
  37. package/templates/shared/dashboard/src/components/Header.astro +29 -0
  38. package/templates/shared/dashboard/src/components/Nav.astro.hbs +57 -0
  39. package/templates/shared/dashboard/src/components/overview/ActivityFeed.tsx +134 -0
  40. package/templates/shared/dashboard/src/components/overview/CostQuadrant.tsx +131 -0
  41. package/templates/shared/dashboard/src/components/overview/ErrorsQuadrant.tsx +113 -0
  42. package/templates/shared/dashboard/src/components/overview/HealthQuadrant.tsx +87 -0
  43. package/templates/shared/dashboard/src/components/overview/MissionControl.tsx +139 -0
  44. package/templates/shared/dashboard/src/components/resources/AllowanceStatus.tsx +44 -0
  45. package/templates/shared/dashboard/src/components/resources/CostCentreOverview.tsx +42 -0
  46. package/templates/shared/dashboard/src/components/resources/ResourceTabs.tsx +69 -0
  47. package/templates/shared/dashboard/src/components/resources/index.ts +3 -0
  48. package/templates/shared/dashboard/src/components/settings/SettingsCard.tsx +21 -0
  49. package/templates/shared/dashboard/src/components/settings/index.ts +1 -0
  50. package/templates/shared/dashboard/src/components/ui/AlertBanner.tsx +39 -0
  51. package/templates/shared/dashboard/src/components/ui/Sparkline.tsx +127 -0
  52. package/templates/shared/dashboard/src/components/ui/StatusDot.tsx +21 -0
  53. package/templates/shared/dashboard/src/components/ui/index.ts +3 -0
  54. package/templates/shared/dashboard/src/env.d.ts.hbs +34 -0
  55. package/templates/shared/dashboard/src/layouts/DashboardLayout.astro +37 -0
  56. package/templates/shared/dashboard/src/lib/fetch.ts +29 -0
  57. package/templates/shared/dashboard/src/lib/types.ts +72 -0
  58. package/templates/shared/dashboard/src/middleware/auth.ts +100 -0
  59. package/templates/shared/dashboard/src/middleware/index.ts +1 -0
  60. package/templates/shared/dashboard/src/pages/api/overview/summary.ts +311 -0
  61. package/templates/shared/dashboard/src/pages/api/usage/circuit-breakers.ts +44 -0
  62. package/templates/shared/dashboard/src/pages/api/usage/status.ts +42 -0
  63. package/templates/shared/dashboard/src/pages/dashboard.astro +11 -0
  64. package/templates/shared/dashboard/src/pages/index.astro +3 -0
  65. package/templates/shared/dashboard/src/pages/resources.astro +11 -0
  66. package/templates/shared/dashboard/src/pages/settings/index.astro +28 -0
  67. package/templates/shared/dashboard/src/styles/global.css +29 -0
  68. package/templates/shared/dashboard/tailwind.config.mjs +9 -0
  69. package/templates/shared/dashboard/tsconfig.json +9 -0
  70. package/templates/shared/dashboard/wrangler.json.hbs +47 -0
  71. package/templates/shared/package.json.hbs +12 -1
  72. package/templates/shared/scripts/ops/backfill-cloudflare-hourly.ts +473 -0
  73. package/templates/shared/scripts/ops/discover-graphql-datasets.ts +482 -0
  74. package/templates/shared/scripts/ops/reset-budget-state.ts +279 -0
  75. package/templates/shared/scripts/ops/validate-pipeline.ts +237 -0
  76. package/templates/shared/scripts/ops/verify-account-completeness.ts +236 -0
  77. package/templates/shared/scripts/validate-schemas.js +61 -0
  78. package/templates/shared/workers/lib/usage/collectors/anthropic.ts +114 -0
  79. package/templates/shared/workers/lib/usage/collectors/apify.ts +96 -0
  80. package/templates/shared/workers/lib/usage/collectors/custom-http.ts +151 -0
  81. package/templates/shared/workers/lib/usage/collectors/deepseek.ts +92 -0
  82. package/templates/shared/workers/lib/usage/collectors/gemini.ts +263 -0
  83. package/templates/shared/workers/lib/usage/collectors/github.ts +362 -0
  84. package/templates/shared/workers/lib/usage/collectors/index.ts +31 -15
  85. package/templates/shared/workers/lib/usage/collectors/minimax.ts +106 -0
  86. package/templates/shared/workers/lib/usage/collectors/openai.ts +171 -0
  87. package/templates/shared/workers/lib/usage/collectors/resend.ts +79 -0
  88. package/templates/shared/workers/lib/usage/collectors/stripe.ts +192 -0
  89. package/templates/shared/workers/lib/usage/shared/types.ts +46 -0
  90. package/templates/shared/workers/platform-usage.ts +98 -8
  91. package/templates/standard/dashboard/src/components/errors/ErrorStats.tsx +53 -0
  92. package/templates/standard/dashboard/src/components/errors/ErrorsTable.tsx +133 -0
  93. package/templates/standard/dashboard/src/components/errors/index.ts +2 -0
  94. package/templates/standard/dashboard/src/components/health/DlqStatusCard.tsx +52 -0
  95. package/templates/standard/dashboard/src/components/health/HealthTabs.tsx +86 -0
  96. package/templates/standard/dashboard/src/components/health/index.ts +2 -0
  97. package/templates/standard/dashboard/src/lib/errors.ts +28 -0
  98. package/templates/standard/dashboard/src/pages/api/errors/index.ts +58 -0
  99. package/templates/standard/dashboard/src/pages/api/errors/stats.ts +55 -0
  100. package/templates/standard/dashboard/src/pages/api/health/dlq.ts +43 -0
  101. package/templates/standard/dashboard/src/pages/errors.astro +13 -0
  102. package/templates/standard/dashboard/src/pages/health.astro +11 -0
  103. package/templates/standard/migrations/009_topology_mapper.sql +65 -0
  104. package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +37 -3
  105. package/templates/standard/workers/lib/error-collector/gap-alerts.ts +32 -1
  106. package/templates/standard/workers/lib/mapper/attribution-check.ts +339 -0
  107. package/templates/standard/workers/lib/mapper/index.ts +7 -0
  108. package/templates/standard/workers/platform-mapper.ts +482 -0
  109. package/templates/standard/workers/platform-sdk-test-client.ts +125 -0
  110. package/templates/standard/wrangler.mapper.jsonc.hbs +85 -0
  111. package/templates/standard/wrangler.sdk-test-client.jsonc.hbs +62 -0
@@ -0,0 +1,44 @@
1
+ interface Allowance {
2
+ service: string;
3
+ metric: string;
4
+ used: number;
5
+ included: number;
6
+ unit: string;
7
+ }
8
+
9
+ interface Props {
10
+ allowances: Allowance[];
11
+ }
12
+
13
+ export function AllowanceStatus({ allowances }: Props) {
14
+ if (allowances.length === 0) {
15
+ return (
16
+ <div className="text-center py-4 text-sm text-gray-500 dark:text-gray-400">
17
+ No allowance data available yet.
18
+ </div>
19
+ );
20
+ }
21
+
22
+ return (
23
+ <div className="space-y-3">
24
+ {allowances.map((a) => {
25
+ const pct = a.included > 0 ? Math.min((a.used / a.included) * 100, 100) : 0;
26
+ const colour = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-green-500';
27
+
28
+ return (
29
+ <div key={`${a.service}-${a.metric}`}>
30
+ <div className="flex justify-between text-sm mb-1">
31
+ <span className="text-gray-700 dark:text-gray-300">{a.service} — {a.metric}</span>
32
+ <span className="text-gray-500 dark:text-gray-400">
33
+ {a.used.toLocaleString()} / {a.included.toLocaleString()} {a.unit}
34
+ </span>
35
+ </div>
36
+ <div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
37
+ <div className={`${colour} h-2 rounded-full transition-all`} style={{ width: `${pct}%` }} />
38
+ </div>
39
+ </div>
40
+ );
41
+ })}
42
+ </div>
43
+ );
44
+ }
@@ -0,0 +1,42 @@
1
+ interface CostRow {
2
+ service: string;
3
+ mtdCost: number;
4
+ pctOfTotal: number;
5
+ }
6
+
7
+ interface Props {
8
+ costs: CostRow[];
9
+ }
10
+
11
+ export function CostCentreOverview({ costs }: Props) {
12
+ if (costs.length === 0) {
13
+ return (
14
+ <div className="text-center py-4 text-sm text-gray-500 dark:text-gray-400">
15
+ No cost data available yet.
16
+ </div>
17
+ );
18
+ }
19
+
20
+ return (
21
+ <div className="overflow-x-auto">
22
+ <table className="w-full text-sm">
23
+ <thead>
24
+ <tr className="border-b border-gray-200 dark:border-gray-700">
25
+ <th className="text-left py-2 px-3 text-gray-500 dark:text-gray-400 font-medium">Service</th>
26
+ <th className="text-right py-2 px-3 text-gray-500 dark:text-gray-400 font-medium">MTD Cost</th>
27
+ <th className="text-right py-2 px-3 text-gray-500 dark:text-gray-400 font-medium">% of Total</th>
28
+ </tr>
29
+ </thead>
30
+ <tbody>
31
+ {costs.map((row) => (
32
+ <tr key={row.service} className="border-b border-gray-100 dark:border-gray-800">
33
+ <td className="py-2 px-3 text-gray-900 dark:text-white">{row.service}</td>
34
+ <td className="py-2 px-3 text-right text-gray-900 dark:text-white">${row.mtdCost.toFixed(2)}</td>
35
+ <td className="py-2 px-3 text-right text-gray-500 dark:text-gray-400">{row.pctOfTotal}%</td>
36
+ </tr>
37
+ ))}
38
+ </tbody>
39
+ </table>
40
+ </div>
41
+ );
42
+ }
@@ -0,0 +1,69 @@
1
+ import { useState, useEffect } from 'react';
2
+
3
+ type Tab = 'overview' | 'workers' | 'databases' | 'storage';
4
+
5
+ export function ResourceTabs() {
6
+ const [activeTab, setActiveTab] = useState<Tab>('overview');
7
+ const [status, setStatus] = useState<{ latestSnapshot: string | null; trackedFeatures: number } | null>(null);
8
+
9
+ useEffect(() => {
10
+ fetch('/api/usage/status')
11
+ .then(res => res.json())
12
+ .then(setStatus)
13
+ .catch(() => {});
14
+ }, []);
15
+
16
+ const tabs: { id: Tab; label: string }[] = [
17
+ { id: 'overview', label: 'Overview' },
18
+ { id: 'workers', label: 'Workers' },
19
+ { id: 'databases', label: 'Databases' },
20
+ { id: 'storage', label: 'Storage' },
21
+ ];
22
+
23
+ return (
24
+ <div>
25
+ <div className="border-b border-gray-200 dark:border-gray-700 mb-4">
26
+ <nav className="flex gap-4">
27
+ {tabs.map(tab => (
28
+ <button
29
+ key={tab.id}
30
+ onClick={() => setActiveTab(tab.id)}
31
+ className={`py-2 px-1 text-sm font-medium border-b-2 transition-colors ${
32
+ activeTab === tab.id
33
+ ? 'border-blue-500 text-blue-600 dark:text-blue-400'
34
+ : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
35
+ }`}
36
+ >
37
+ {tab.label}
38
+ </button>
39
+ ))}
40
+ </nav>
41
+ </div>
42
+
43
+ <div className="space-y-4">
44
+ {activeTab === 'overview' && (
45
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
46
+ <div className="metric-card">
47
+ <div className="metric-title">Last Snapshot</div>
48
+ <div className="metric-value text-lg">{status?.latestSnapshot ?? 'N/A'}</div>
49
+ </div>
50
+ <div className="metric-card">
51
+ <div className="metric-title">Tracked Features</div>
52
+ <div className="metric-value">{status?.trackedFeatures ?? 0}</div>
53
+ </div>
54
+ <div className="metric-card">
55
+ <div className="metric-title">Status</div>
56
+ <div className="metric-value text-lg text-green-600 dark:text-green-400">Active</div>
57
+ </div>
58
+ </div>
59
+ )}
60
+ {activeTab !== 'overview' && (
61
+ <div className="text-center py-8 text-gray-500 dark:text-gray-400">
62
+ <p>Resource details for {activeTab} will appear here once data is collected.</p>
63
+ <p className="text-sm mt-2">Configure collectors in services.yaml and run sync:config.</p>
64
+ </div>
65
+ )}
66
+ </div>
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,3 @@
1
+ export { ResourceTabs } from './ResourceTabs';
2
+ export { CostCentreOverview } from './CostCentreOverview';
3
+ export { AllowanceStatus } from './AllowanceStatus';
@@ -0,0 +1,21 @@
1
+ interface Props {
2
+ label: string;
3
+ value: string;
4
+ description: string;
5
+ }
6
+
7
+ export function SettingsCard({ label, value, description }: Props) {
8
+ return (
9
+ <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
10
+ <div className="flex items-center justify-between">
11
+ <div>
12
+ <h3 className="text-sm font-medium text-gray-900 dark:text-white">{label}</h3>
13
+ <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{description}</p>
14
+ </div>
15
+ <span className="text-sm font-mono text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">
16
+ {value}
17
+ </span>
18
+ </div>
19
+ </div>
20
+ );
21
+ }
@@ -0,0 +1 @@
1
+ export { SettingsCard } from './SettingsCard';
@@ -0,0 +1,39 @@
1
+ import { useState } from 'react';
2
+
3
+ interface Props {
4
+ type: 'error' | 'warning' | 'info';
5
+ message: string;
6
+ detail?: string;
7
+ dismissable?: boolean;
8
+ }
9
+
10
+ export function AlertBanner({ type, message, detail, dismissable = true }: Props) {
11
+ const [dismissed, setDismissed] = useState(false);
12
+ if (dismissed) return null;
13
+
14
+ const styles = {
15
+ error: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200',
16
+ warning: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800 text-yellow-800 dark:text-yellow-200',
17
+ info: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200',
18
+ };
19
+
20
+ return (
21
+ <div className={`border rounded-lg p-4 ${styles[type]}`}>
22
+ <div className="flex items-start justify-between gap-2">
23
+ <div>
24
+ <p className="font-medium">{message}</p>
25
+ {detail && <p className="text-sm mt-1 opacity-80">{detail}</p>}
26
+ </div>
27
+ {dismissable && (
28
+ <button
29
+ onClick={() => setDismissed(true)}
30
+ className="text-current opacity-50 hover:opacity-100 p-1"
31
+ aria-label="Dismiss"
32
+ >
33
+ x
34
+ </button>
35
+ )}
36
+ </div>
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Sparkline Component
3
+ *
4
+ * Inline SVG sparkline for activity trends.
5
+ * Minimal, industrial aesthetic.
6
+ */
7
+
8
+ import { useMemo } from 'react';
9
+
10
+ interface SparklineProps {
11
+ data: number[];
12
+ width?: number;
13
+ height?: number;
14
+ color?: string;
15
+ strokeWidth?: number;
16
+ showFill?: boolean;
17
+ className?: string;
18
+ }
19
+
20
+ export function Sparkline({
21
+ data,
22
+ width = 60,
23
+ height = 20,
24
+ color = '#3b82f6',
25
+ strokeWidth = 1.5,
26
+ showFill = true,
27
+ className = '',
28
+ }: SparklineProps) {
29
+ const pathData = useMemo(() => {
30
+ if (data.length < 2) return { line: '', fill: '' };
31
+
32
+ const min = Math.min(...data);
33
+ const max = Math.max(...data);
34
+ const range = max - min || 1;
35
+
36
+ // Padding to prevent clipping
37
+ const padding = 2;
38
+ const chartWidth = width - padding * 2;
39
+ const chartHeight = height - padding * 2;
40
+
41
+ const points = data.map((value, index) => {
42
+ const x = padding + (index / (data.length - 1)) * chartWidth;
43
+ const y = padding + chartHeight - ((value - min) / range) * chartHeight;
44
+ return { x, y };
45
+ });
46
+
47
+ // Build line path
48
+ const linePath = points
49
+ .map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`))
50
+ .join(' ');
51
+
52
+ // Build fill path (close at bottom)
53
+ const fillPath = `${linePath} L ${points[points.length - 1].x} ${height - padding} L ${padding} ${height - padding} Z`;
54
+
55
+ return { line: linePath, fill: fillPath };
56
+ }, [data, width, height]);
57
+
58
+ if (data.length < 2) {
59
+ return (
60
+ <svg
61
+ width={width}
62
+ height={height}
63
+ className={className}
64
+ viewBox={`0 0 ${width} ${height}`}
65
+ aria-hidden="true"
66
+ >
67
+ <line
68
+ x1={2}
69
+ y1={height / 2}
70
+ x2={width - 2}
71
+ y2={height / 2}
72
+ stroke={color}
73
+ strokeWidth={strokeWidth}
74
+ strokeOpacity={0.3}
75
+ strokeDasharray="2 2"
76
+ />
77
+ </svg>
78
+ );
79
+ }
80
+
81
+ return (
82
+ <svg
83
+ width={width}
84
+ height={height}
85
+ className={className}
86
+ viewBox={`0 0 ${width} ${height}`}
87
+ aria-hidden="true"
88
+ >
89
+ {/* Fill gradient */}
90
+ {showFill && (
91
+ <>
92
+ <defs>
93
+ <linearGradient
94
+ id={`sparkline-fill-${color.replace('#', '')}`}
95
+ x1="0"
96
+ y1="0"
97
+ x2="0"
98
+ y2="1"
99
+ >
100
+ <stop offset="0%" stopColor={color} stopOpacity={0.3} />
101
+ <stop offset="100%" stopColor={color} stopOpacity={0} />
102
+ </linearGradient>
103
+ </defs>
104
+ <path d={pathData.fill} fill={`url(#sparkline-fill-${color.replace('#', '')})`} />
105
+ </>
106
+ )}
107
+ {/* Line */}
108
+ <path
109
+ d={pathData.line}
110
+ fill="none"
111
+ stroke={color}
112
+ strokeWidth={strokeWidth}
113
+ strokeLinecap="round"
114
+ strokeLinejoin="round"
115
+ />
116
+ {/* End dot */}
117
+ <circle
118
+ cx={width - 2}
119
+ cy={pathData.line ? parseFloat(pathData.line.split(' ').slice(-1)[0]) : height / 2}
120
+ r={2}
121
+ fill={color}
122
+ />
123
+ </svg>
124
+ );
125
+ }
126
+
127
+ export default Sparkline;
@@ -0,0 +1,21 @@
1
+ import clsx from 'clsx';
2
+
3
+ interface Props {
4
+ status: 'green' | 'yellow' | 'red' | 'gray';
5
+ size?: 'sm' | 'md';
6
+ }
7
+
8
+ export function StatusDot({ status, size = 'sm' }: Props) {
9
+ return (
10
+ <span
11
+ className={clsx(
12
+ 'inline-block rounded-full',
13
+ size === 'sm' ? 'w-2 h-2' : 'w-3 h-3',
14
+ status === 'green' && 'bg-green-500',
15
+ status === 'yellow' && 'bg-yellow-500',
16
+ status === 'red' && 'bg-red-500',
17
+ status === 'gray' && 'bg-gray-400',
18
+ )}
19
+ />
20
+ );
21
+ }
@@ -0,0 +1,3 @@
1
+ export { Sparkline } from './Sparkline';
2
+ export { StatusDot } from './StatusDot';
3
+ export { AlertBanner } from './AlertBanner';
@@ -0,0 +1,34 @@
1
+ /// <reference types="astro/client" />
2
+
3
+ interface Env {
4
+ PLATFORM_DB: D1Database;
5
+ PLATFORM_CACHE: KVNamespace;
6
+ USAGE_API: Fetcher;
7
+ {{#if isStandard}}
8
+ ERROR_COLLECTOR_API: Fetcher;
9
+ {{/if}}
10
+ {{#if isFull}}
11
+ PATTERN_DISCOVERY_API: Fetcher;
12
+ NOTIFICATIONS_API: Fetcher;
13
+ SETTINGS_API: Fetcher;
14
+ SEARCH_API: Fetcher;
15
+ {{/if}}
16
+ CF_ACCESS_AUD?: string;
17
+ CF_ACCESS_ISSUER?: string;
18
+ CF_ACCESS_TEAM_DOMAIN?: string;
19
+ }
20
+
21
+ declare namespace App {
22
+ interface Locals {
23
+ runtime?: {
24
+ env: Env;
25
+ };
26
+ user?: {
27
+ email?: string;
28
+ name?: string;
29
+ sub?: string;
30
+ audience?: unknown;
31
+ issuer?: unknown;
32
+ };
33
+ }
34
+ }
@@ -0,0 +1,37 @@
1
+ ---
2
+ import Nav from '../components/Nav.astro';
3
+ import Header from '../components/Header.astro';
4
+ import '../styles/global.css';
5
+
6
+ interface Props {
7
+ title: string;
8
+ }
9
+
10
+ const { title } = Astro.props;
11
+ ---
12
+
13
+ <!doctype html>
14
+ <html lang="en" class="h-full">
15
+ <head>
16
+ <meta charset="utf-8" />
17
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
18
+ <title>{title} | Platform Dashboard</title>
19
+ <script is:inline>
20
+ if (localStorage.getItem('theme') === 'dark' ||
21
+ (!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
22
+ document.documentElement.classList.add('dark');
23
+ }
24
+ </script>
25
+ </head>
26
+ <body class="h-full bg-gray-50 dark:bg-gray-900">
27
+ <div class="flex h-full">
28
+ <Nav />
29
+ <div class="flex-1 flex flex-col min-w-0">
30
+ <Header />
31
+ <main class="flex-1 overflow-y-auto p-4 lg:p-6">
32
+ <slot />
33
+ </main>
34
+ </div>
35
+ </div>
36
+ </body>
37
+ </html>
@@ -0,0 +1,29 @@
1
+ const inflight = new Map<string, Promise<unknown>>();
2
+
3
+ export async function fetchWithDedup<T>(url: string, init?: RequestInit): Promise<T> {
4
+ const key = `${init?.method ?? 'GET'}:${url}`;
5
+
6
+ if (inflight.has(key)) {
7
+ return inflight.get(key) as Promise<T>;
8
+ }
9
+
10
+ const promise = (async () => {
11
+ const controller = new AbortController();
12
+ const timeout = setTimeout(() => controller.abort(), 10000);
13
+
14
+ try {
15
+ const res = await fetch(url, { ...init, signal: controller.signal });
16
+ clearTimeout(timeout);
17
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
18
+ return (await res.json()) as T;
19
+ } catch (error) {
20
+ clearTimeout(timeout);
21
+ throw error;
22
+ } finally {
23
+ inflight.delete(key);
24
+ }
25
+ })();
26
+
27
+ inflight.set(key, promise);
28
+ return promise;
29
+ }
@@ -0,0 +1,72 @@
1
+ export interface OverviewSummary {
2
+ health: {
3
+ servicesTotal: number;
4
+ servicesUp: number;
5
+ servicesDown: number;
6
+ uptimePct: number;
7
+ lastAuditScore: number | null;
8
+ lastAuditDate: string | null;
9
+ };
10
+ errors: {
11
+ p0Count: number;
12
+ p1Count: number;
13
+ p2Count: number;
14
+ p3Count: number;
15
+ p4Count: number;
16
+ newToday: number;
17
+ dailyTrend: number[];
18
+ topErrors: Array<{
19
+ fingerprint: string;
20
+ message: string;
21
+ script_name: string;
22
+ priority: string;
23
+ occurrence_count: number;
24
+ }>;
25
+ };
26
+ costs: {
27
+ mtdSpend: number;
28
+ dailyBurnRate: number;
29
+ projectedMonthly: number;
30
+ budgetPct: number;
31
+ monthlyBudget: number;
32
+ dailyTrend: number[];
33
+ };
34
+ activity: {
35
+ notifications: Array<{
36
+ id: string;
37
+ title: string;
38
+ category: string;
39
+ priority: string;
40
+ source: string;
41
+ created_at: number;
42
+ action_url: string | null;
43
+ }>;
44
+ pendingPatterns: number;
45
+ };
46
+ alerts: {
47
+ hasP0P1: boolean;
48
+ trippedBreakers: number;
49
+ warningBreakers: number;
50
+ servicesDown: number;
51
+ };
52
+ dataQuality?: {
53
+ latestSnapshot: string | null;
54
+ snapshotAgeMinutes: number;
55
+ status: 'fresh' | 'stale' | 'unknown';
56
+ };
57
+ }
58
+
59
+ export interface CircuitBreakerState {
60
+ key: string;
61
+ feature: string;
62
+ status: string;
63
+ reason: string | null;
64
+ trippedAt: string | null;
65
+ }
66
+
67
+ export interface UsageStatus {
68
+ status: string;
69
+ latestSnapshot: string | null;
70
+ latestCost: number;
71
+ trackedFeatures: number;
72
+ }
@@ -0,0 +1,100 @@
1
+ import type { MiddlewareHandler } from 'astro';
2
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
3
+
4
+ type AccessEnv = {
5
+ CF_ACCESS_AUD?: string;
6
+ CF_ACCESS_ISSUER?: string;
7
+ CF_ACCESS_TEAM_DOMAIN?: string;
8
+ };
9
+
10
+ const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
11
+
12
+ function resolveIssuer(env: AccessEnv): string | null {
13
+ if (env.CF_ACCESS_ISSUER) {
14
+ return env.CF_ACCESS_ISSUER.replace(/\/$/, '');
15
+ }
16
+ if (env.CF_ACCESS_TEAM_DOMAIN) {
17
+ const domain = env.CF_ACCESS_TEAM_DOMAIN.replace(/https?:\/\//, '').replace(/\/$/, '');
18
+ return `https://${domain}`;
19
+ }
20
+ return null;
21
+ }
22
+
23
+ function parseAudiences(env: AccessEnv): string[] {
24
+ if (!env.CF_ACCESS_AUD) return [];
25
+ return env.CF_ACCESS_AUD.split(',').map((aud) => aud.trim()).filter(Boolean);
26
+ }
27
+
28
+ function getRemoteJwks(issuer: string) {
29
+ if (!jwksCache.has(issuer)) {
30
+ const jwksUrl = new URL('/cdn-cgi/access/certs', issuer);
31
+ jwksCache.set(issuer, createRemoteJWKSet(jwksUrl));
32
+ }
33
+ return jwksCache.get(issuer)!;
34
+ }
35
+
36
+ function isDevelopment() {
37
+ return typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.DEV;
38
+ }
39
+
40
+ export const onRequest: MiddlewareHandler = async (context, next) => {
41
+ const url = new URL(context.request.url);
42
+ const pathname = url.pathname;
43
+
44
+ // Allow API routes to handle their own auth if needed
45
+ if (pathname.startsWith('/api/')) {
46
+ return next();
47
+ }
48
+
49
+ const runtimeEnv = (context.locals.runtime?.env ?? {}) as AccessEnv;
50
+ const issuer = resolveIssuer(runtimeEnv);
51
+ const audiences = parseAudiences(runtimeEnv);
52
+
53
+ // Bypass auth during build/prerender
54
+ if (!issuer && audiences.length === 0) {
55
+ const jwt = context.request.headers.get('cf-access-jwt-assertion');
56
+ if (!jwt) {
57
+ return next();
58
+ }
59
+ }
60
+
61
+ const jwt =
62
+ context.request.headers.get('cf-access-jwt-assertion') ??
63
+ context.request.headers.get('Cf-Access-Jwt-Assertion');
64
+
65
+ if (!jwt) {
66
+ if (isDevelopment() && !issuer) {
67
+ console.warn('[auth] Missing Access JWT in development; skipping validation');
68
+ return next();
69
+ }
70
+ return new Response('Unauthorized', { status: 401 });
71
+ }
72
+
73
+ if (!issuer || audiences.length === 0) {
74
+ if (isDevelopment()) {
75
+ console.warn('[auth] Missing Access configuration in development; skipping validation');
76
+ return next();
77
+ }
78
+ console.error('[auth] Access configuration missing: expected CF_ACCESS_ISSUER (or CF_ACCESS_TEAM_DOMAIN) and CF_ACCESS_AUD');
79
+ return new Response('Server configuration error', { status: 500 });
80
+ }
81
+
82
+ try {
83
+ const jwks = getRemoteJwks(issuer);
84
+ const verification = await jwtVerify(jwt, jwks, { issuer, audience: audiences });
85
+ const payload = verification.payload as Record<string, unknown>;
86
+
87
+ context.locals.user = {
88
+ email: typeof payload.email === 'string' ? payload.email : undefined,
89
+ name: typeof payload.name === 'string' ? payload.name : undefined,
90
+ sub: typeof payload.sub === 'string' ? payload.sub : undefined,
91
+ audience: payload.aud,
92
+ issuer: payload.iss,
93
+ };
94
+
95
+ return next();
96
+ } catch (error) {
97
+ console.error('[auth] Access JWT validation failed', error);
98
+ return new Response('Unauthorized', { status: 401 });
99
+ }
100
+ };
@@ -0,0 +1 @@
1
+ export { onRequest } from './auth';