@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.
- package/dist/templates.d.ts +1 -1
- package/dist/templates.js +121 -2
- package/package.json +1 -1
- package/templates/full/config/audit-targets.yaml +72 -0
- package/templates/full/dashboard/src/components/notifications/NotificationBell.tsx +30 -0
- package/templates/full/dashboard/src/components/notifications/NotificationList.tsx +116 -0
- package/templates/full/dashboard/src/components/notifications/index.ts +2 -0
- package/templates/full/dashboard/src/components/patterns/PatternStats.tsx +60 -0
- package/templates/full/dashboard/src/components/patterns/SuggestionsQueue.tsx +115 -0
- package/templates/full/dashboard/src/components/patterns/index.ts +2 -0
- package/templates/full/dashboard/src/components/search/SearchModal.tsx +108 -0
- package/templates/full/dashboard/src/pages/api/notifications/index.ts +47 -0
- package/templates/full/dashboard/src/pages/api/notifications/unread-count.ts +31 -0
- package/templates/full/dashboard/src/pages/api/patterns/approve.ts +55 -0
- package/templates/full/dashboard/src/pages/api/patterns/index.ts +36 -0
- package/templates/full/dashboard/src/pages/api/patterns/reject.ts +54 -0
- package/templates/full/dashboard/src/pages/api/search/index.ts +74 -0
- package/templates/full/dashboard/src/pages/notifications.astro +11 -0
- package/templates/full/migrations/008_auditor.sql +99 -0
- package/templates/full/migrations/010_pricing_versions.sql +110 -0
- package/templates/full/migrations/011_multi_account.sql +51 -0
- package/templates/full/scripts/ops/set-kv-pricing.ts +182 -0
- package/templates/full/workers/lib/ai-judge-schema.ts +181 -0
- package/templates/full/workers/lib/auditor/comprehensive-report.ts +407 -0
- package/templates/full/workers/lib/auditor/feature-coverage.ts +348 -0
- package/templates/full/workers/lib/auditor/index.ts +9 -0
- package/templates/full/workers/lib/auditor/types.ts +167 -0
- package/templates/full/workers/platform-auditor.ts +1071 -0
- package/templates/full/wrangler.auditor.jsonc.hbs +75 -0
- package/templates/shared/.github/workflows/platform-check.yml.hbs +28 -0
- package/templates/shared/config/observability.yaml.hbs +276 -0
- package/templates/shared/contracts/schemas/envelope.v1.schema.json +64 -0
- package/templates/shared/contracts/schemas/error_report.v1.schema.json +65 -0
- package/templates/shared/contracts/types/telemetry-envelope.types.ts +139 -0
- package/templates/shared/dashboard/astro.config.mjs +21 -0
- package/templates/shared/dashboard/package.json.hbs +29 -0
- package/templates/shared/dashboard/src/components/Header.astro +29 -0
- package/templates/shared/dashboard/src/components/Nav.astro.hbs +57 -0
- package/templates/shared/dashboard/src/components/overview/ActivityFeed.tsx +134 -0
- package/templates/shared/dashboard/src/components/overview/CostQuadrant.tsx +131 -0
- package/templates/shared/dashboard/src/components/overview/ErrorsQuadrant.tsx +113 -0
- package/templates/shared/dashboard/src/components/overview/HealthQuadrant.tsx +87 -0
- package/templates/shared/dashboard/src/components/overview/MissionControl.tsx +139 -0
- package/templates/shared/dashboard/src/components/resources/AllowanceStatus.tsx +44 -0
- package/templates/shared/dashboard/src/components/resources/CostCentreOverview.tsx +42 -0
- package/templates/shared/dashboard/src/components/resources/ResourceTabs.tsx +69 -0
- package/templates/shared/dashboard/src/components/resources/index.ts +3 -0
- package/templates/shared/dashboard/src/components/settings/SettingsCard.tsx +21 -0
- package/templates/shared/dashboard/src/components/settings/index.ts +1 -0
- package/templates/shared/dashboard/src/components/ui/AlertBanner.tsx +39 -0
- package/templates/shared/dashboard/src/components/ui/Sparkline.tsx +127 -0
- package/templates/shared/dashboard/src/components/ui/StatusDot.tsx +21 -0
- package/templates/shared/dashboard/src/components/ui/index.ts +3 -0
- package/templates/shared/dashboard/src/env.d.ts.hbs +34 -0
- package/templates/shared/dashboard/src/layouts/DashboardLayout.astro +37 -0
- package/templates/shared/dashboard/src/lib/fetch.ts +29 -0
- package/templates/shared/dashboard/src/lib/types.ts +72 -0
- package/templates/shared/dashboard/src/middleware/auth.ts +100 -0
- package/templates/shared/dashboard/src/middleware/index.ts +1 -0
- package/templates/shared/dashboard/src/pages/api/overview/summary.ts +311 -0
- package/templates/shared/dashboard/src/pages/api/usage/circuit-breakers.ts +44 -0
- package/templates/shared/dashboard/src/pages/api/usage/status.ts +42 -0
- package/templates/shared/dashboard/src/pages/dashboard.astro +11 -0
- package/templates/shared/dashboard/src/pages/index.astro +3 -0
- package/templates/shared/dashboard/src/pages/resources.astro +11 -0
- package/templates/shared/dashboard/src/pages/settings/index.astro +28 -0
- package/templates/shared/dashboard/src/styles/global.css +29 -0
- package/templates/shared/dashboard/tailwind.config.mjs +9 -0
- package/templates/shared/dashboard/tsconfig.json +9 -0
- package/templates/shared/dashboard/wrangler.json.hbs +47 -0
- package/templates/shared/package.json.hbs +12 -1
- package/templates/shared/scripts/ops/backfill-cloudflare-hourly.ts +473 -0
- package/templates/shared/scripts/ops/discover-graphql-datasets.ts +482 -0
- package/templates/shared/scripts/ops/reset-budget-state.ts +279 -0
- package/templates/shared/scripts/ops/validate-pipeline.ts +237 -0
- package/templates/shared/scripts/ops/verify-account-completeness.ts +236 -0
- package/templates/shared/scripts/validate-schemas.js +61 -0
- package/templates/shared/workers/lib/usage/collectors/anthropic.ts +114 -0
- package/templates/shared/workers/lib/usage/collectors/apify.ts +96 -0
- package/templates/shared/workers/lib/usage/collectors/custom-http.ts +151 -0
- package/templates/shared/workers/lib/usage/collectors/deepseek.ts +92 -0
- package/templates/shared/workers/lib/usage/collectors/gemini.ts +263 -0
- package/templates/shared/workers/lib/usage/collectors/github.ts +362 -0
- package/templates/shared/workers/lib/usage/collectors/index.ts +31 -15
- package/templates/shared/workers/lib/usage/collectors/minimax.ts +106 -0
- package/templates/shared/workers/lib/usage/collectors/openai.ts +171 -0
- package/templates/shared/workers/lib/usage/collectors/resend.ts +79 -0
- package/templates/shared/workers/lib/usage/collectors/stripe.ts +192 -0
- package/templates/shared/workers/lib/usage/shared/types.ts +46 -0
- package/templates/shared/workers/platform-usage.ts +98 -8
- package/templates/standard/dashboard/src/components/errors/ErrorStats.tsx +53 -0
- package/templates/standard/dashboard/src/components/errors/ErrorsTable.tsx +133 -0
- package/templates/standard/dashboard/src/components/errors/index.ts +2 -0
- package/templates/standard/dashboard/src/components/health/DlqStatusCard.tsx +52 -0
- package/templates/standard/dashboard/src/components/health/HealthTabs.tsx +86 -0
- package/templates/standard/dashboard/src/components/health/index.ts +2 -0
- package/templates/standard/dashboard/src/lib/errors.ts +28 -0
- package/templates/standard/dashboard/src/pages/api/errors/index.ts +58 -0
- package/templates/standard/dashboard/src/pages/api/errors/stats.ts +55 -0
- package/templates/standard/dashboard/src/pages/api/health/dlq.ts +43 -0
- package/templates/standard/dashboard/src/pages/errors.astro +13 -0
- package/templates/standard/dashboard/src/pages/health.astro +11 -0
- package/templates/standard/migrations/009_topology_mapper.sql +65 -0
- package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +37 -3
- package/templates/standard/workers/lib/error-collector/gap-alerts.ts +32 -1
- package/templates/standard/workers/lib/mapper/attribution-check.ts +339 -0
- package/templates/standard/workers/lib/mapper/index.ts +7 -0
- package/templates/standard/workers/platform-mapper.ts +482 -0
- package/templates/standard/workers/platform-sdk-test-client.ts +125 -0
- package/templates/standard/wrangler.mapper.jsonc.hbs +85 -0
- 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,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,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';
|