@littlebearapps/platform-admin-sdk 1.4.2 → 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.
- package/dist/templates.d.ts +1 -1
- package/dist/templates.js +232 -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/ActivePatterns.tsx +62 -0
- package/templates/full/dashboard/src/components/patterns/PatternStats.tsx +60 -0
- package/templates/full/dashboard/src/components/patterns/PatternTabs.tsx +116 -0
- package/templates/full/dashboard/src/components/patterns/SuggestionsQueue.tsx +115 -0
- package/templates/full/dashboard/src/components/patterns/SystemPatterns.tsx +52 -0
- package/templates/full/dashboard/src/components/patterns/index.ts +5 -0
- package/templates/full/dashboard/src/components/reports/GapDetectionReport.tsx +69 -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/search/SearchModal.tsx +108 -0
- package/templates/full/dashboard/src/pages/api/notifications/[id]/read.ts +37 -0
- package/templates/full/dashboard/src/pages/api/notifications/index.ts +47 -0
- package/templates/full/dashboard/src/pages/api/notifications/read-all.ts +28 -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/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/index.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/reject.ts +54 -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/index.ts +74 -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/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/scripts/ops/universal-backfill.ts +147 -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/contract-check.yml.hbs +42 -0
- package/templates/shared/.github/workflows/dashboard-deploy.yml.hbs +39 -0
- package/templates/shared/.github/workflows/platform-check.yml.hbs +28 -0
- package/templates/shared/.github/workflows/security.yml +33 -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 +59 -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/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/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/Sparkline.tsx +127 -0
- package/templates/shared/dashboard/src/components/ui/StatusDot.tsx +21 -0
- package/templates/shared/dashboard/src/components/ui/Toast.tsx +44 -0
- package/templates/shared/dashboard/src/components/ui/index.ts +9 -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/env.d.ts.hbs +34 -0
- package/templates/shared/dashboard/src/layouts/DashboardLayout.astro +37 -0
- package/templates/shared/dashboard/src/lib/cloudflare/costs.ts +21 -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/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/overview/summary.ts +311 -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/circuit-breakers.ts +44 -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/usage/status.ts +42 -0
- package/templates/shared/dashboard/src/pages/api/user/identity.ts +11 -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/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/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/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 +17 -1
- package/templates/shared/scripts/ops/backfill-cloudflare-daily.ts +145 -0
- package/templates/shared/scripts/ops/backfill-cloudflare-hourly.ts +473 -0
- package/templates/shared/scripts/ops/backfill-monthly-rollups.ts +125 -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-controls.js +141 -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/tests/contract/validate-schemas.test.ts +130 -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/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/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/CircuitBreakerEvents.tsx +69 -0
- package/templates/standard/dashboard/src/components/health/CircuitBreakerPanel.tsx +97 -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 +4 -0
- package/templates/standard/dashboard/src/lib/errors.ts +28 -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/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/audit-history.ts +37 -0
- package/templates/standard/dashboard/src/pages/api/health/dlq.ts +43 -0
- package/templates/standard/dashboard/src/pages/circuit-breakers.astro +13 -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/tests/unit/error-collector/capture.test.ts +106 -0
- package/templates/standard/tests/unit/error-collector/fingerprint.test.ts +155 -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,60 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
interface Stats {
|
|
4
|
+
pending: number;
|
|
5
|
+
shadow: number;
|
|
6
|
+
approved: number;
|
|
7
|
+
rejected: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function PatternStats() {
|
|
11
|
+
const [stats, setStats] = useState<Stats | null>(null);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
Promise.all([
|
|
15
|
+
fetch('/api/patterns?status=pending').then(r => r.json()),
|
|
16
|
+
fetch('/api/patterns?status=shadow').then(r => r.json()),
|
|
17
|
+
fetch('/api/patterns?status=approved').then(r => r.json()),
|
|
18
|
+
])
|
|
19
|
+
.then(([pending, shadow, approved]) => {
|
|
20
|
+
setStats({
|
|
21
|
+
pending: (pending as { suggestions: unknown[] }).suggestions?.length ?? 0,
|
|
22
|
+
shadow: (shadow as { suggestions: unknown[] }).suggestions?.length ?? 0,
|
|
23
|
+
approved: (approved as { suggestions: unknown[] }).suggestions?.length ?? 0,
|
|
24
|
+
rejected: 0,
|
|
25
|
+
});
|
|
26
|
+
})
|
|
27
|
+
.catch(() => {});
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
if (!stats) {
|
|
31
|
+
return (
|
|
32
|
+
<div className="grid grid-cols-4 gap-4">
|
|
33
|
+
{Array.from({ length: 4 }).map((_, i) => (
|
|
34
|
+
<div key={i} className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 animate-pulse">
|
|
35
|
+
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-12 mb-2" />
|
|
36
|
+
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-8" />
|
|
37
|
+
</div>
|
|
38
|
+
))}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const items = [
|
|
44
|
+
{ label: 'Pending', value: stats.pending, colour: 'text-yellow-600 dark:text-yellow-400' },
|
|
45
|
+
{ label: 'Shadow', value: stats.shadow, colour: 'text-purple-600 dark:text-purple-400' },
|
|
46
|
+
{ label: 'Approved', value: stats.approved, colour: 'text-green-600 dark:text-green-400' },
|
|
47
|
+
{ label: 'Rejected', value: stats.rejected, colour: 'text-red-600 dark:text-red-400' },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="grid grid-cols-4 gap-4">
|
|
52
|
+
{items.map(item => (
|
|
53
|
+
<div key={item.label} className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
54
|
+
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">{item.label}</div>
|
|
55
|
+
<div className={`text-2xl font-bold mt-1 ${item.colour}`}>{item.value}</div>
|
|
56
|
+
</div>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { EmptyState } from '../../components/ui/EmptyState';
|
|
3
|
+
import { LoadingSkeleton } from '../../components/ui/LoadingSkeleton';
|
|
4
|
+
|
|
5
|
+
type Tab = 'pending' | 'shadow' | 'approved' | 'rejected';
|
|
6
|
+
|
|
7
|
+
interface Pattern {
|
|
8
|
+
id: number;
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
match_rule: string;
|
|
12
|
+
status: string;
|
|
13
|
+
source: string;
|
|
14
|
+
created_at: string;
|
|
15
|
+
match_count?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function PatternTabs() {
|
|
19
|
+
const [activeTab, setActiveTab] = useState<Tab>('pending');
|
|
20
|
+
const [patterns, setPatterns] = useState<Pattern[]>([]);
|
|
21
|
+
const [loading, setLoading] = useState(true);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
setLoading(true);
|
|
25
|
+
fetch(`/api/patterns?status=${activeTab}`)
|
|
26
|
+
.then(res => res.json())
|
|
27
|
+
.then((data: { patterns: Pattern[] }) => { setPatterns(data.patterns ?? []); setLoading(false); })
|
|
28
|
+
.catch(() => setLoading(false));
|
|
29
|
+
}, [activeTab]);
|
|
30
|
+
|
|
31
|
+
const tabs: { id: Tab; label: string }[] = [
|
|
32
|
+
{ id: 'pending', label: 'Pending' },
|
|
33
|
+
{ id: 'shadow', label: 'Shadow' },
|
|
34
|
+
{ id: 'approved', label: 'Approved' },
|
|
35
|
+
{ id: 'rejected', label: 'Rejected' },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const handleAction = async (id: number, action: 'approve' | 'reject') => {
|
|
39
|
+
const endpoint = action === 'approve' ? '/api/patterns/approve' : '/api/patterns/reject';
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch(endpoint, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: { 'Content-Type': 'application/json' },
|
|
44
|
+
body: JSON.stringify({ id }),
|
|
45
|
+
});
|
|
46
|
+
if (res.ok) {
|
|
47
|
+
setPatterns(prev => prev.filter(p => p.id !== id));
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// Silent failure — user can retry
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div>
|
|
56
|
+
<div className="border-b border-gray-200 dark:border-gray-700 mb-4">
|
|
57
|
+
<nav className="flex gap-4">
|
|
58
|
+
{tabs.map(tab => (
|
|
59
|
+
<button
|
|
60
|
+
key={tab.id}
|
|
61
|
+
onClick={() => setActiveTab(tab.id)}
|
|
62
|
+
className={`py-2 px-1 text-sm font-medium border-b-2 transition-colors ${
|
|
63
|
+
activeTab === tab.id
|
|
64
|
+
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
|
65
|
+
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
|
66
|
+
}`}
|
|
67
|
+
>
|
|
68
|
+
{tab.label}
|
|
69
|
+
</button>
|
|
70
|
+
))}
|
|
71
|
+
</nav>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{loading ? (
|
|
75
|
+
<LoadingSkeleton lines={4} />
|
|
76
|
+
) : patterns.length === 0 ? (
|
|
77
|
+
<EmptyState title={`No ${activeTab} patterns`} description="Patterns will appear here as they are discovered." />
|
|
78
|
+
) : (
|
|
79
|
+
<div className="space-y-3">
|
|
80
|
+
{patterns.map(p => (
|
|
81
|
+
<div key={p.id} className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
82
|
+
<div className="flex items-start justify-between gap-3">
|
|
83
|
+
<div className="min-w-0">
|
|
84
|
+
<p className="text-sm font-medium text-gray-900 dark:text-white">{p.name}</p>
|
|
85
|
+
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{p.description}</p>
|
|
86
|
+
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1 font-mono truncate">{p.match_rule}</p>
|
|
87
|
+
<div className="flex items-center gap-3 mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
88
|
+
<span>Source: {p.source}</span>
|
|
89
|
+
{p.match_count !== undefined && <span>Matches: {p.match_count}</span>}
|
|
90
|
+
<span>{new Date(p.created_at).toLocaleDateString()}</span>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
{(activeTab === 'pending' || activeTab === 'shadow') && (
|
|
94
|
+
<div className="flex gap-2 shrink-0">
|
|
95
|
+
<button
|
|
96
|
+
onClick={() => handleAction(p.id, 'approve')}
|
|
97
|
+
className="text-xs px-3 py-1 rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-900/50"
|
|
98
|
+
>
|
|
99
|
+
Approve
|
|
100
|
+
</button>
|
|
101
|
+
<button
|
|
102
|
+
onClick={() => handleAction(p.id, 'reject')}
|
|
103
|
+
className="text-xs px-3 py-1 rounded bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-900/50"
|
|
104
|
+
>
|
|
105
|
+
Reject
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
))}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
interface PatternSuggestion {
|
|
4
|
+
id: number;
|
|
5
|
+
pattern_type: string;
|
|
6
|
+
pattern_value: string;
|
|
7
|
+
error_type: string;
|
|
8
|
+
priority: string;
|
|
9
|
+
status: string;
|
|
10
|
+
match_count: number;
|
|
11
|
+
source: string;
|
|
12
|
+
created_at: string;
|
|
13
|
+
reviewed_at: string | null;
|
|
14
|
+
reviewer_notes: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function SuggestionsQueue() {
|
|
18
|
+
const [suggestions, setSuggestions] = useState<PatternSuggestion[]>([]);
|
|
19
|
+
const [filter, setFilter] = useState<string>('pending');
|
|
20
|
+
const [actioningId, setActioningId] = useState<number | null>(null);
|
|
21
|
+
|
|
22
|
+
const loadSuggestions = useCallback(() => {
|
|
23
|
+
fetch(`/api/patterns?status=${filter}`)
|
|
24
|
+
.then(res => res.json())
|
|
25
|
+
.then((data: { suggestions: PatternSuggestion[] }) => setSuggestions(data.suggestions ?? []))
|
|
26
|
+
.catch(() => {});
|
|
27
|
+
}, [filter]);
|
|
28
|
+
|
|
29
|
+
useEffect(() => { loadSuggestions(); }, [loadSuggestions]);
|
|
30
|
+
|
|
31
|
+
async function handleAction(id: number, action: 'approve' | 'reject') {
|
|
32
|
+
setActioningId(id);
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(`/api/patterns/${action}`, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37
|
+
body: JSON.stringify({ id }),
|
|
38
|
+
});
|
|
39
|
+
if (res.ok) {
|
|
40
|
+
setSuggestions(prev => prev.filter(s => s.id !== id));
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// Silently fail
|
|
44
|
+
}
|
|
45
|
+
setActioningId(null);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="space-y-4">
|
|
50
|
+
<div className="flex gap-2">
|
|
51
|
+
{['pending', 'shadow', 'approved', 'rejected'].map(s => (
|
|
52
|
+
<button
|
|
53
|
+
key={s}
|
|
54
|
+
onClick={() => setFilter(s)}
|
|
55
|
+
className={`text-sm px-3 py-1 rounded-full transition-colors ${
|
|
56
|
+
filter === s
|
|
57
|
+
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
|
|
58
|
+
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
|
|
59
|
+
}`}
|
|
60
|
+
>
|
|
61
|
+
{s.charAt(0).toUpperCase() + s.slice(1)}
|
|
62
|
+
</button>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{suggestions.length === 0 ? (
|
|
67
|
+
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
|
68
|
+
No {filter} patterns.
|
|
69
|
+
</div>
|
|
70
|
+
) : (
|
|
71
|
+
<div className="space-y-3">
|
|
72
|
+
{suggestions.map(s => (
|
|
73
|
+
<div key={s.id} className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
74
|
+
<div className="flex items-start justify-between gap-4">
|
|
75
|
+
<div className="flex-1 min-w-0">
|
|
76
|
+
<div className="flex items-center gap-2 mb-1">
|
|
77
|
+
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300 font-mono">
|
|
78
|
+
{s.pattern_type}
|
|
79
|
+
</span>
|
|
80
|
+
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
81
|
+
{s.source} · {s.match_count} matches
|
|
82
|
+
</span>
|
|
83
|
+
</div>
|
|
84
|
+
<p className="text-sm text-gray-900 dark:text-white font-mono truncate">{s.pattern_value}</p>
|
|
85
|
+
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
86
|
+
{s.error_type} · {s.priority} · {new Date(s.created_at).toLocaleDateString('en-AU')}
|
|
87
|
+
</p>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{filter === 'pending' && (
|
|
91
|
+
<div className="flex gap-2 flex-shrink-0">
|
|
92
|
+
<button
|
|
93
|
+
onClick={() => handleAction(s.id, 'approve')}
|
|
94
|
+
disabled={actioningId === s.id}
|
|
95
|
+
className="text-xs px-3 py-1.5 rounded bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/60 disabled:opacity-50"
|
|
96
|
+
>
|
|
97
|
+
Approve
|
|
98
|
+
</button>
|
|
99
|
+
<button
|
|
100
|
+
onClick={() => handleAction(s.id, 'reject')}
|
|
101
|
+
disabled={actioningId === s.id}
|
|
102
|
+
className="text-xs px-3 py-1.5 rounded bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/60 disabled:opacity-50"
|
|
103
|
+
>
|
|
104
|
+
Reject
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
))}
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { LoadingSkeleton } from '../../components/ui/LoadingSkeleton';
|
|
3
|
+
|
|
4
|
+
interface PatternStats {
|
|
5
|
+
byStatus: Record<string, number>;
|
|
6
|
+
total: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function SystemPatterns() {
|
|
10
|
+
const [stats, setStats] = useState<PatternStats | null>(null);
|
|
11
|
+
const [loading, setLoading] = useState(true);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
fetch('/api/patterns/stats')
|
|
15
|
+
.then((r) => r.json())
|
|
16
|
+
.then((data: PatternStats) => {
|
|
17
|
+
setStats(data);
|
|
18
|
+
setLoading(false);
|
|
19
|
+
})
|
|
20
|
+
.catch(() => setLoading(false));
|
|
21
|
+
}, []);
|
|
22
|
+
|
|
23
|
+
if (loading) return <LoadingSkeleton lines={2} />;
|
|
24
|
+
if (!stats) return null;
|
|
25
|
+
|
|
26
|
+
const statusLabels: Record<string, { label: string; colour: string }> = {
|
|
27
|
+
approved: { label: 'Approved', colour: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' },
|
|
28
|
+
pending: { label: 'Pending', colour: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' },
|
|
29
|
+
shadow: { label: 'Shadow', colour: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' },
|
|
30
|
+
rejected: { label: 'Rejected', colour: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' },
|
|
31
|
+
stale: { label: 'Stale', colour: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400' },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div>
|
|
36
|
+
<div className="flex items-center gap-2 mb-3">
|
|
37
|
+
<span className="text-sm font-medium text-gray-900 dark:text-white">Pattern Overview</span>
|
|
38
|
+
<span className="text-xs text-gray-500 dark:text-gray-400">({stats.total} total)</span>
|
|
39
|
+
</div>
|
|
40
|
+
<div className="flex flex-wrap gap-2">
|
|
41
|
+
{Object.entries(stats.byStatus).map(([status, count]) => {
|
|
42
|
+
const meta = statusLabels[status] ?? { label: status, colour: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400' };
|
|
43
|
+
return (
|
|
44
|
+
<span key={status} className={`text-xs px-2.5 py-1 rounded-full font-medium ${meta.colour}`}>
|
|
45
|
+
{meta.label}: {count}
|
|
46
|
+
</span>
|
|
47
|
+
);
|
|
48
|
+
})}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { LoadingSkeleton } from '../../components/ui/LoadingSkeleton';
|
|
3
|
+
import { EmptyState } from '../../components/ui/EmptyState';
|
|
4
|
+
|
|
5
|
+
interface UsageReport {
|
|
6
|
+
snapshot_date: string;
|
|
7
|
+
d1_reads: number;
|
|
8
|
+
d1_writes: number;
|
|
9
|
+
kv_reads: number;
|
|
10
|
+
kv_writes: number;
|
|
11
|
+
worker_requests: number;
|
|
12
|
+
total_cost_usd: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function GapDetectionReport() {
|
|
16
|
+
const [daily, setDaily] = useState<UsageReport[]>([]);
|
|
17
|
+
const [loading, setLoading] = useState(true);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
fetch('/api/reports/usage?days=14')
|
|
21
|
+
.then((r) => r.json())
|
|
22
|
+
.then((data: { daily: UsageReport[] }) => {
|
|
23
|
+
setDaily(data.daily ?? []);
|
|
24
|
+
setLoading(false);
|
|
25
|
+
})
|
|
26
|
+
.catch(() => setLoading(false));
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
if (loading) return <LoadingSkeleton lines={5} />;
|
|
30
|
+
if (daily.length === 0) return <EmptyState title="No usage data" description="Daily rollup data will appear after collection." />;
|
|
31
|
+
|
|
32
|
+
const gapDays = daily.filter((d) => d.d1_reads === 0 && d.d1_writes === 0 && d.worker_requests === 0);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="space-y-3">
|
|
36
|
+
<div className="flex items-center gap-3">
|
|
37
|
+
<span className="text-sm font-medium text-gray-900 dark:text-white">14-Day Coverage</span>
|
|
38
|
+
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
|
39
|
+
gapDays.length === 0
|
|
40
|
+
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
|
41
|
+
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
|
42
|
+
}`}>
|
|
43
|
+
{daily.length - gapDays.length}/{daily.length} days
|
|
44
|
+
</span>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div className="flex gap-0.5">
|
|
48
|
+
{daily.map((d) => {
|
|
49
|
+
const hasData = d.d1_reads > 0 || d.d1_writes > 0 || d.worker_requests > 0;
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
key={d.snapshot_date}
|
|
53
|
+
title={`${d.snapshot_date}: $${d.total_cost_usd.toFixed(2)}`}
|
|
54
|
+
className={`h-6 flex-1 rounded-sm ${
|
|
55
|
+
hasData ? 'bg-green-500 dark:bg-green-600' : 'bg-red-300 dark:bg-red-700'
|
|
56
|
+
}`}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
})}
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{gapDays.length > 0 && (
|
|
63
|
+
<p className="text-xs text-yellow-600 dark:text-yellow-400">
|
|
64
|
+
{gapDays.length} day(s) with zero data: {gapDays.map((d) => d.snapshot_date).join(', ')}
|
|
65
|
+
</p>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { LoadingSkeleton } from '../../components/ui/LoadingSkeleton';
|
|
3
|
+
import { EmptyState } from '../../components/ui/EmptyState';
|
|
4
|
+
|
|
5
|
+
interface AuditReport {
|
|
6
|
+
id: number;
|
|
7
|
+
project: string;
|
|
8
|
+
scan_type: string;
|
|
9
|
+
ai_judge_score: number;
|
|
10
|
+
sdk_score: number;
|
|
11
|
+
observability_score: number;
|
|
12
|
+
cost_protection_score: number;
|
|
13
|
+
security_score: number;
|
|
14
|
+
scan_date: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function SdkAuditReport() {
|
|
18
|
+
const [reports, setReports] = useState<AuditReport[]>([]);
|
|
19
|
+
const [loading, setLoading] = useState(true);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
fetch('/api/reports/audit?limit=20')
|
|
23
|
+
.then((r) => r.json())
|
|
24
|
+
.then((data: { reports: AuditReport[] }) => {
|
|
25
|
+
setReports(data.reports ?? []);
|
|
26
|
+
setLoading(false);
|
|
27
|
+
})
|
|
28
|
+
.catch(() => setLoading(false));
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
if (loading) return <LoadingSkeleton lines={5} />;
|
|
32
|
+
if (reports.length === 0) return <EmptyState title="No audit reports" description="Run the platform auditor to generate reports." />;
|
|
33
|
+
|
|
34
|
+
const scoreColour = (score: number): string => {
|
|
35
|
+
if (score >= 90) return 'text-green-600 dark:text-green-400';
|
|
36
|
+
if (score >= 70) return 'text-yellow-600 dark:text-yellow-400';
|
|
37
|
+
return 'text-red-600 dark:text-red-400';
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="overflow-x-auto">
|
|
42
|
+
<table className="w-full text-sm">
|
|
43
|
+
<thead>
|
|
44
|
+
<tr className="border-b border-gray-200 dark:border-gray-700">
|
|
45
|
+
<th className="text-left py-2 px-2 font-medium text-gray-500 dark:text-gray-400">Project</th>
|
|
46
|
+
<th className="text-left py-2 px-2 font-medium text-gray-500 dark:text-gray-400">Type</th>
|
|
47
|
+
<th className="text-right py-2 px-2 font-medium text-gray-500 dark:text-gray-400">Overall</th>
|
|
48
|
+
<th className="text-right py-2 px-2 font-medium text-gray-500 dark:text-gray-400">SDK</th>
|
|
49
|
+
<th className="text-right py-2 px-2 font-medium text-gray-500 dark:text-gray-400">Obs</th>
|
|
50
|
+
<th className="text-right py-2 px-2 font-medium text-gray-500 dark:text-gray-400">Cost</th>
|
|
51
|
+
<th className="text-right py-2 px-2 font-medium text-gray-500 dark:text-gray-400">Sec</th>
|
|
52
|
+
<th className="text-right py-2 px-2 font-medium text-gray-500 dark:text-gray-400">Date</th>
|
|
53
|
+
</tr>
|
|
54
|
+
</thead>
|
|
55
|
+
<tbody>
|
|
56
|
+
{reports.map((r) => (
|
|
57
|
+
<tr key={r.id} className="border-b border-gray-100 dark:border-gray-800">
|
|
58
|
+
<td className="py-1.5 px-2 text-gray-900 dark:text-white">{r.project}</td>
|
|
59
|
+
<td className="py-1.5 px-2 text-gray-500 dark:text-gray-400">{r.scan_type}</td>
|
|
60
|
+
<td className={`py-1.5 px-2 text-right font-mono ${scoreColour(r.ai_judge_score)}`}>{r.ai_judge_score}</td>
|
|
61
|
+
<td className={`py-1.5 px-2 text-right font-mono ${scoreColour(r.sdk_score)}`}>{r.sdk_score}</td>
|
|
62
|
+
<td className={`py-1.5 px-2 text-right font-mono ${scoreColour(r.observability_score)}`}>{r.observability_score}</td>
|
|
63
|
+
<td className={`py-1.5 px-2 text-right font-mono ${scoreColour(r.cost_protection_score)}`}>{r.cost_protection_score}</td>
|
|
64
|
+
<td className={`py-1.5 px-2 text-right font-mono ${scoreColour(r.security_score)}`}>{r.security_score}</td>
|
|
65
|
+
<td className="py-1.5 px-2 text-right text-gray-400 dark:text-gray-500">{new Date(r.scan_date).toLocaleDateString()}</td>
|
|
66
|
+
</tr>
|
|
67
|
+
))}
|
|
68
|
+
</tbody>
|
|
69
|
+
</table>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
interface SearchResult {
|
|
4
|
+
type: string;
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
subtitle: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function SearchModal() {
|
|
11
|
+
const [open, setOpen] = useState(false);
|
|
12
|
+
const [query, setQuery] = useState('');
|
|
13
|
+
const [results, setResults] = useState<SearchResult[]>([]);
|
|
14
|
+
const [loading, setLoading] = useState(false);
|
|
15
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
16
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
|
17
|
+
|
|
18
|
+
// Cmd+K / Ctrl+K listener
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
21
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
setOpen(prev => !prev);
|
|
24
|
+
}
|
|
25
|
+
if (e.key === 'Escape') {
|
|
26
|
+
setOpen(false);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
30
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (open && inputRef.current) {
|
|
35
|
+
inputRef.current.focus();
|
|
36
|
+
}
|
|
37
|
+
}, [open]);
|
|
38
|
+
|
|
39
|
+
const search = useCallback((q: string) => {
|
|
40
|
+
if (q.length < 2) {
|
|
41
|
+
setResults([]);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
setLoading(true);
|
|
45
|
+
fetch(`/api/search?q=${encodeURIComponent(q)}`)
|
|
46
|
+
.then(res => res.json())
|
|
47
|
+
.then((data: { results: SearchResult[] }) => {
|
|
48
|
+
setResults(data.results ?? []);
|
|
49
|
+
setLoading(false);
|
|
50
|
+
})
|
|
51
|
+
.catch(() => setLoading(false));
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
function handleInput(value: string) {
|
|
55
|
+
setQuery(value);
|
|
56
|
+
clearTimeout(debounceRef.current);
|
|
57
|
+
debounceRef.current = setTimeout(() => search(value), 300);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!open) return null;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]" onClick={() => setOpen(false)}>
|
|
64
|
+
<div className="fixed inset-0 bg-black/50" />
|
|
65
|
+
<div
|
|
66
|
+
className="relative w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700"
|
|
67
|
+
onClick={(e) => e.stopPropagation()}
|
|
68
|
+
>
|
|
69
|
+
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
|
70
|
+
<span className="text-gray-400 text-sm">Search</span>
|
|
71
|
+
<input
|
|
72
|
+
ref={inputRef}
|
|
73
|
+
type="text"
|
|
74
|
+
value={query}
|
|
75
|
+
onChange={(e) => handleInput(e.target.value)}
|
|
76
|
+
placeholder="Search errors, notifications..."
|
|
77
|
+
className="flex-1 bg-transparent text-gray-900 dark:text-white placeholder-gray-400 outline-none text-sm"
|
|
78
|
+
/>
|
|
79
|
+
<kbd className="hidden md:inline text-xs text-gray-400 border border-gray-300 dark:border-gray-600 rounded px-1.5 py-0.5">
|
|
80
|
+
ESC
|
|
81
|
+
</kbd>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div className="max-h-80 overflow-y-auto p-2">
|
|
85
|
+
{loading && (
|
|
86
|
+
<div className="text-center py-4 text-sm text-gray-500 dark:text-gray-400">Searching...</div>
|
|
87
|
+
)}
|
|
88
|
+
{!loading && results.length === 0 && query.length >= 2 && (
|
|
89
|
+
<div className="text-center py-4 text-sm text-gray-500 dark:text-gray-400">No results found.</div>
|
|
90
|
+
)}
|
|
91
|
+
{!loading && results.map((r) => (
|
|
92
|
+
<a
|
|
93
|
+
key={`${r.type}-${r.id}`}
|
|
94
|
+
href={r.type === 'error' ? `/errors` : `/notifications`}
|
|
95
|
+
className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
96
|
+
>
|
|
97
|
+
<span className="text-xs font-mono text-gray-400 dark:text-gray-500 uppercase">{r.type}</span>
|
|
98
|
+
<div className="flex-1 min-w-0">
|
|
99
|
+
<div className="text-sm text-gray-900 dark:text-white truncate">{r.title}</div>
|
|
100
|
+
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{r.subtitle}</div>
|
|
101
|
+
</div>
|
|
102
|
+
</a>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
|
|
3
|
+
export const POST: APIRoute = async ({ params, locals }) => {
|
|
4
|
+
const db = (locals.runtime?.env as { PLATFORM_DB?: D1Database } | undefined)?.PLATFORM_DB;
|
|
5
|
+
|
|
6
|
+
if (!db) {
|
|
7
|
+
return new Response(JSON.stringify({ error: 'Database not available' }), {
|
|
8
|
+
status: 503,
|
|
9
|
+
headers: { 'Content-Type': 'application/json' },
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const id = params.id;
|
|
14
|
+
if (!id) {
|
|
15
|
+
return new Response(JSON.stringify({ error: 'Missing notification id' }), {
|
|
16
|
+
status: 400,
|
|
17
|
+
headers: { 'Content-Type': 'application/json' },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
await db
|
|
23
|
+
.prepare(`UPDATE notifications SET read_at = unixepoch() WHERE id = ? AND read_at IS NULL`)
|
|
24
|
+
.bind(id)
|
|
25
|
+
.run();
|
|
26
|
+
|
|
27
|
+
return new Response(
|
|
28
|
+
JSON.stringify({ ok: true }),
|
|
29
|
+
{ headers: { 'Content-Type': 'application/json' } }
|
|
30
|
+
);
|
|
31
|
+
} catch {
|
|
32
|
+
return new Response(JSON.stringify({ error: 'Failed to mark as read' }), {
|
|
33
|
+
status: 500,
|
|
34
|
+
headers: { 'Content-Type': 'application/json' },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
};
|