@littlebearapps/platform-admin-sdk 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +2 -2
  2. package/dist/templates.d.ts +1 -1
  3. package/dist/templates.js +86 -2
  4. package/package.json +1 -1
  5. package/templates/full/dashboard/src/components/reports/DigestStats.tsx +151 -0
  6. package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
  7. package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
  8. package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
  9. package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
  10. package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
  11. package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
  12. package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
  13. package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
  14. package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
  15. package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
  16. package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
  17. package/templates/full/dashboard/src/lib/search/api.ts +258 -0
  18. package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
  19. package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
  20. package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
  21. package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
  22. package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
  23. package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
  24. package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
  25. package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
  26. package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
  27. package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
  28. package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
  29. package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
  30. package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
  31. package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
  32. package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
  33. package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
  34. package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
  35. package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
  36. package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
  37. package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
  38. package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
  39. package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
  40. package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
  41. package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
  42. package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
  43. package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
  44. package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
  45. package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
  46. package/templates/shared/tests/helpers/mock-storage.ts +166 -0
  47. package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
  48. package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
  49. package/templates/shared/tests/unit/billing.test.ts +331 -0
  50. package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
  51. package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
  52. package/templates/shared/tests/unit/control.test.ts +226 -0
  53. package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
  54. package/templates/shared/tests/unit/economics.test.ts +365 -0
  55. package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
  56. package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
  57. package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
  58. package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
  59. package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
  60. package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
  61. package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
  62. package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
  63. package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
  64. package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
  65. package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
  66. package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
  67. package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
  68. package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
  69. package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
  70. package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
  71. package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
  72. package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
  73. package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
  74. package/templates/standard/tests/unit/error-collector/github.test.ts +187 -0
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Project Health Table Component
3
+ * Shows per-project gap analysis coverage scores
4
+ */
5
+ import { useState, useEffect } from 'react';
6
+
7
+ interface ResourceBreakdown {
8
+ resourceType: string;
9
+ hoursWithData: number;
10
+ coveragePct: number;
11
+ }
12
+
13
+ interface ProjectHealth {
14
+ project: string;
15
+ coveragePct: number;
16
+ hoursWithData: number;
17
+ expectedHours: number;
18
+ status: 'healthy' | 'warning' | 'critical';
19
+ lastDataHour: string | null;
20
+ resourceBreakdown?: ResourceBreakdown[];
21
+ }
22
+
23
+ interface ProjectHealthResponse {
24
+ projects: ProjectHealth[];
25
+ summary: {
26
+ total: number;
27
+ healthy: number;
28
+ warning: number;
29
+ critical: number;
30
+ averageCoverage: number;
31
+ };
32
+ generatedAt: string;
33
+ }
34
+
35
+ function StatusBadge({ status }: { status: 'healthy' | 'warning' | 'critical' }) {
36
+ const colours: Record<string, string> = {
37
+ healthy: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
38
+ warning: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
39
+ critical: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
40
+ };
41
+ const labels: Record<string, string> = {
42
+ healthy: '✓ Healthy',
43
+ warning: '⚠ Warning',
44
+ critical: '✗ Critical',
45
+ };
46
+ return (
47
+ <span className={`px-2 py-0.5 text-xs font-semibold rounded ${colours[status]}`}>
48
+ {labels[status]}
49
+ </span>
50
+ );
51
+ }
52
+
53
+ function CoverageBar({ percent }: { percent: number }) {
54
+ const getColour = (pct: number) => {
55
+ if (pct >= 90) return 'bg-green-500';
56
+ if (pct >= 70) return 'bg-yellow-500';
57
+ return 'bg-red-500';
58
+ };
59
+
60
+ return (
61
+ <div className="flex items-center gap-2">
62
+ <div className="w-24 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
63
+ <div
64
+ className={`h-full ${getColour(percent)} transition-all`}
65
+ style={{ width: `${Math.min(percent, 100)}%` }}
66
+ />
67
+ </div>
68
+ <span className={`text-sm font-medium ${
69
+ percent >= 90 ? 'text-green-600 dark:text-green-400' :
70
+ percent >= 70 ? 'text-yellow-600 dark:text-yellow-400' :
71
+ 'text-red-600 dark:text-red-400'
72
+ }`}>
73
+ {percent.toFixed(0)}%
74
+ </span>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ export function ProjectHealthTable() {
80
+ const [data, setData] = useState<ProjectHealthResponse | null>(null);
81
+ const [loading, setLoading] = useState(true);
82
+ const [error, setError] = useState<string | null>(null);
83
+ const [expandedProject, setExpandedProject] = useState<string | null>(null);
84
+
85
+ useEffect(() => {
86
+ async function fetchData() {
87
+ try {
88
+ const res = await fetch('/api/usage/gaps/projects');
89
+ if (!res.ok) throw new Error('Failed to fetch project health data');
90
+ const json = await res.json();
91
+ // API wraps response in { success, data: { projects, summary } }
92
+ const responseData = json.data || json;
93
+ setData(responseData);
94
+ } catch (e) {
95
+ setError(e instanceof Error ? e.message : 'Unknown error');
96
+ } finally {
97
+ setLoading(false);
98
+ }
99
+ }
100
+ fetchData();
101
+ }, []);
102
+
103
+ if (loading) {
104
+ return (
105
+ <div className="space-y-4">
106
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
107
+ {[...Array(4)].map((_, i) => (
108
+ <div key={i} className="bg-white dark:bg-gray-800 rounded-lg border p-4 animate-pulse">
109
+ <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-2"></div>
110
+ <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-12"></div>
111
+ </div>
112
+ ))}
113
+ </div>
114
+ <div className="bg-white dark:bg-gray-800 rounded-lg border p-4 animate-pulse">
115
+ <div className="h-48 bg-gray-200 dark:bg-gray-700 rounded"></div>
116
+ </div>
117
+ </div>
118
+ );
119
+ }
120
+
121
+ if (error) {
122
+ return (
123
+ <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 text-red-700 dark:text-red-300">
124
+ Error loading project health data: {error}
125
+ </div>
126
+ );
127
+ }
128
+
129
+ if (!data) return null;
130
+
131
+ const { projects, summary } = data;
132
+
133
+ return (
134
+ <div className="space-y-6">
135
+ {/* Summary Stats */}
136
+ <div className="grid grid-cols-2 md:grid-cols-5 gap-4">
137
+ <div className="bg-white dark:bg-gray-800 rounded-lg border p-4 border-l-4 border-l-blue-500">
138
+ <p className="text-sm font-medium text-gray-600 dark:text-gray-400">Total Projects</p>
139
+ <p className="text-2xl font-bold mt-1">{summary.total}</p>
140
+ </div>
141
+ <div className="bg-white dark:bg-gray-800 rounded-lg border p-4 border-l-4 border-l-green-500">
142
+ <p className="text-sm font-medium text-gray-600 dark:text-gray-400">Healthy</p>
143
+ <p className="text-2xl font-bold mt-1 text-green-600">{summary.healthy}</p>
144
+ </div>
145
+ <div className="bg-white dark:bg-gray-800 rounded-lg border p-4 border-l-4 border-l-yellow-500">
146
+ <p className="text-sm font-medium text-gray-600 dark:text-gray-400">Warning</p>
147
+ <p className="text-2xl font-bold mt-1 text-yellow-600">{summary.warning}</p>
148
+ </div>
149
+ <div className="bg-white dark:bg-gray-800 rounded-lg border p-4 border-l-4 border-l-red-500">
150
+ <p className="text-sm font-medium text-gray-600 dark:text-gray-400">Critical</p>
151
+ <p className="text-2xl font-bold mt-1 text-red-600">{summary.critical}</p>
152
+ </div>
153
+ <div className="bg-white dark:bg-gray-800 rounded-lg border p-4 border-l-4 border-l-purple-500">
154
+ <p className="text-sm font-medium text-gray-600 dark:text-gray-400">Avg Coverage</p>
155
+ <p className="text-2xl font-bold mt-1">{summary.averageCoverage.toFixed(0)}%</p>
156
+ </div>
157
+ </div>
158
+
159
+ {/* Project Health Table */}
160
+ <div className="bg-white dark:bg-gray-800 rounded-lg border overflow-hidden">
161
+ <div className="px-4 py-3 border-b">
162
+ <h3 className="font-semibold text-gray-900 dark:text-white">Per-Project Health</h3>
163
+ <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
164
+ Resource coverage: active resources in last 24h vs total known. Healthy ≥90%, Warning 70-90%, Critical &lt;70%
165
+ </p>
166
+ </div>
167
+ {projects.length === 0 ? (
168
+ <div className="p-8 text-center text-gray-500">No projects found in registry</div>
169
+ ) : (
170
+ <div className="overflow-x-auto">
171
+ <table className="w-full">
172
+ <thead className="bg-gray-50 dark:bg-gray-900/50">
173
+ <tr>
174
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Project</th>
175
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Coverage</th>
176
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Resources</th>
177
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
178
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Last Data</th>
179
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
180
+ </tr>
181
+ </thead>
182
+ <tbody>
183
+ {projects.map((project) => (
184
+ <>
185
+ <tr
186
+ key={project.project}
187
+ className="border-b hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer"
188
+ onClick={() => setExpandedProject(
189
+ expandedProject === project.project ? null : project.project
190
+ )}
191
+ >
192
+ <td className="px-4 py-3 text-sm font-medium">{project.project}</td>
193
+ <td className="px-4 py-3">
194
+ <CoverageBar percent={Math.min(project.coveragePct, 100)} />
195
+ </td>
196
+ <td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
197
+ {project.hoursWithData}/{project.expectedHours}
198
+ </td>
199
+ <td className="px-4 py-3">
200
+ <StatusBadge status={project.status} />
201
+ </td>
202
+ <td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
203
+ {project.lastDataHour
204
+ ? new Date(project.lastDataHour).toLocaleString('en-AU', {
205
+ dateStyle: 'short',
206
+ timeStyle: 'short'
207
+ })
208
+ : 'No data'}
209
+ </td>
210
+ <td className="px-4 py-3 text-sm text-gray-400">
211
+ {project.resourceBreakdown && project.resourceBreakdown.length > 0 && (
212
+ <span>{expandedProject === project.project ? '▼' : '▶'}</span>
213
+ )}
214
+ </td>
215
+ </tr>
216
+ {/* Expanded resource breakdown */}
217
+ {expandedProject === project.project && project.resourceBreakdown && (
218
+ <tr key={`${project.project}-breakdown`}>
219
+ <td colSpan={6} className="px-4 py-3 bg-gray-50 dark:bg-gray-900/30">
220
+ <div className="pl-4">
221
+ <p className="text-xs font-medium text-gray-500 uppercase mb-2">Resource Breakdown</p>
222
+ <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
223
+ {project.resourceBreakdown.map((resource) => (
224
+ <div
225
+ key={resource.resourceType}
226
+ className="bg-white dark:bg-gray-800 rounded border p-2"
227
+ >
228
+ <p className="text-xs font-medium text-gray-600 dark:text-gray-400 truncate">
229
+ {resource.resourceType}
230
+ </p>
231
+ <div className="flex items-center justify-between mt-1">
232
+ <span className="text-sm">{resource.hoursWithData}</span>
233
+ <CoverageBar percent={Math.min(resource.coveragePct, 100)} />
234
+ </div>
235
+ </div>
236
+ ))}
237
+ </div>
238
+ </div>
239
+ </td>
240
+ </tr>
241
+ )}
242
+ </>
243
+ ))}
244
+ </tbody>
245
+ </table>
246
+ </div>
247
+ )}
248
+ </div>
249
+ </div>
250
+ );
251
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Warning Digests Table Component
3
+ * Interactive table with filtering and expandable rows
4
+ */
5
+ import { useState, useEffect } from 'react';
6
+ import type { WarningDigest, DigestFilter } from '../../lib/reports/types';
7
+
8
+ interface WarningDigestsTableProps {
9
+ initialFilter?: DigestFilter;
10
+ }
11
+
12
+ function formatTimestamp(ts: number): string {
13
+ const date = new Date(ts * 1000);
14
+ const now = new Date();
15
+ const diffMs = now.getTime() - date.getTime();
16
+ const diffMins = Math.floor(diffMs / 60000);
17
+ const diffHours = Math.floor(diffMs / 3600000);
18
+ const diffDays = Math.floor(diffMs / 86400000);
19
+
20
+ if (diffMins < 1) return 'Just now';
21
+ if (diffMins < 60) return `${diffMins}m ago`;
22
+ if (diffHours < 24) return `${diffHours}h ago`;
23
+ if (diffDays < 7) return `${diffDays}d ago`;
24
+ return date.toLocaleDateString();
25
+ }
26
+
27
+ function formatDate(dateStr: string): string {
28
+ const date = new Date(dateStr);
29
+ return date.toLocaleDateString('en-AU', { weekday: 'short', month: 'short', day: 'numeric' });
30
+ }
31
+
32
+ function ScriptBadge({ script }: { script: string }) {
33
+ // Assign colours based on project prefix
34
+ let colour = 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300';
35
+ if (script.startsWith('project-')) {
36
+ colour = 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
37
+ } else if (script.startsWith('platform') || script.includes('platform')) {
38
+ colour = 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
39
+ } else if (script.startsWith('error-collector')) {
40
+ colour = 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
41
+ }
42
+
43
+ return (
44
+ <span className={`px-2 py-0.5 text-xs font-medium rounded ${colour}`}>
45
+ {script}
46
+ </span>
47
+ );
48
+ }
49
+
50
+ function DigestRow({ digest, onExpand, expanded }: { digest: WarningDigest; onExpand: () => void; expanded: boolean }) {
51
+ return (
52
+ <>
53
+ <tr
54
+ className="border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer"
55
+ onClick={onExpand}
56
+ >
57
+ <td className="px-4 py-3">
58
+ <span className="text-sm font-medium text-gray-900 dark:text-white">
59
+ {formatDate(digest.digest_date)}
60
+ </span>
61
+ </td>
62
+ <td className="px-4 py-3">
63
+ <ScriptBadge script={digest.script_name} />
64
+ </td>
65
+ <td className="px-4 py-3 max-w-md">
66
+ <div className="text-sm text-gray-700 dark:text-gray-300 truncate">
67
+ {digest.normalized_message}
68
+ </div>
69
+ </td>
70
+ <td className="px-4 py-3 text-center">
71
+ <span className="inline-flex items-center justify-center px-2.5 py-0.5 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">
72
+ {digest.occurrence_count}
73
+ </span>
74
+ </td>
75
+ <td className="px-4 py-3">
76
+ {digest.github_issue_url ? (
77
+ <a
78
+ href={digest.github_issue_url}
79
+ target="_blank"
80
+ rel="noopener noreferrer"
81
+ onClick={(e) => e.stopPropagation()}
82
+ className="text-blue-600 dark:text-blue-400 hover:underline text-sm"
83
+ >
84
+ #{digest.github_issue_number}
85
+ </a>
86
+ ) : (
87
+ <span className="text-gray-400 dark:text-gray-500 text-sm">-</span>
88
+ )}
89
+ </td>
90
+ </tr>
91
+ {expanded && (
92
+ <tr className="bg-gray-50 dark:bg-gray-800/30">
93
+ <td colSpan={5} className="px-4 py-4">
94
+ <div className="space-y-3">
95
+ {/* Warning Details */}
96
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
97
+ <div>
98
+ <span className="text-gray-500 dark:text-gray-400">Fingerprint:</span>
99
+ <span className="ml-2 font-mono text-xs text-gray-900 dark:text-white">
100
+ {digest.fingerprint.slice(0, 12)}...
101
+ </span>
102
+ </div>
103
+ <div>
104
+ <span className="text-gray-500 dark:text-gray-400">First Seen:</span>
105
+ <span className="ml-2 text-gray-900 dark:text-white">
106
+ {formatTimestamp(digest.first_occurrence_at)}
107
+ </span>
108
+ </div>
109
+ <div>
110
+ <span className="text-gray-500 dark:text-gray-400">Last Seen:</span>
111
+ <span className="ml-2 text-gray-900 dark:text-white">
112
+ {formatTimestamp(digest.last_occurrence_at)}
113
+ </span>
114
+ </div>
115
+ <div>
116
+ <span className="text-gray-500 dark:text-gray-400">Repository:</span>
117
+ <span className="ml-2 text-gray-900 dark:text-white">{digest.github_repo}</span>
118
+ </div>
119
+ </div>
120
+
121
+ {/* Full Message */}
122
+ <div>
123
+ <h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Full Message</h4>
124
+ <pre className="bg-gray-900 text-gray-100 p-3 rounded text-xs overflow-x-auto whitespace-pre-wrap">
125
+ {digest.normalized_message}
126
+ </pre>
127
+ </div>
128
+ </div>
129
+ </td>
130
+ </tr>
131
+ )}
132
+ </>
133
+ );
134
+ }
135
+
136
+ export function WarningDigestsTable({ initialFilter = {} }: WarningDigestsTableProps) {
137
+ const [digests, setDigests] = useState<WarningDigest[]>([]);
138
+ const [loading, setLoading] = useState(true);
139
+ const [error, setError] = useState<string | null>(null);
140
+ const [total, setTotal] = useState(0);
141
+ const [expandedRow, setExpandedRow] = useState<string | null>(null);
142
+
143
+ // Filter state
144
+ const [filter, setFilter] = useState<DigestFilter>({
145
+ limit: 25,
146
+ offset: 0,
147
+ days: 30,
148
+ ...initialFilter,
149
+ });
150
+
151
+ useEffect(() => {
152
+ async function fetchDigests() {
153
+ setLoading(true);
154
+ try {
155
+ const params = new URLSearchParams();
156
+ if (filter.script) params.set('script', filter.script);
157
+ if (filter.days) params.set('days', String(filter.days));
158
+ if (filter.limit) params.set('limit', String(filter.limit));
159
+ if (filter.offset) params.set('offset', String(filter.offset));
160
+
161
+ const response = await fetch(`/api/reports/digests?${params.toString()}`);
162
+ if (!response.ok) throw new Error('Failed to fetch digests');
163
+ const data = await response.json();
164
+ setDigests(data.digests);
165
+ setTotal(data.total);
166
+ } catch (e) {
167
+ setError(e instanceof Error ? e.message : 'Unknown error');
168
+ } finally {
169
+ setLoading(false);
170
+ }
171
+ }
172
+ fetchDigests();
173
+ }, [filter]);
174
+
175
+ const updateFilter = (key: keyof DigestFilter, value: string | number | undefined) => {
176
+ setFilter((prev) => ({
177
+ ...prev,
178
+ [key]: value || undefined,
179
+ offset: key !== 'offset' ? 0 : (value as number), // Reset offset on filter change
180
+ }));
181
+ };
182
+
183
+ return (
184
+ <div className="space-y-4">
185
+ {/* Filters */}
186
+ <div className="flex flex-wrap gap-3 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
187
+ <select
188
+ value={filter.days || 30}
189
+ onChange={(e) => updateFilter('days', parseInt(e.target.value, 10))}
190
+ className="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
191
+ >
192
+ <option value="7">Last 7 days</option>
193
+ <option value="14">Last 14 days</option>
194
+ <option value="30">Last 30 days</option>
195
+ <option value="60">Last 60 days</option>
196
+ <option value="90">Last 90 days</option>
197
+ </select>
198
+
199
+ <select
200
+ value={filter.script || ''}
201
+ onChange={(e) => updateFilter('script', e.target.value)}
202
+ className="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
203
+ >
204
+ <option value="">All workers</option>
205
+ </select>
206
+
207
+ <div className="ml-auto text-sm text-gray-500 dark:text-gray-400 self-center">
208
+ {total} digests
209
+ </div>
210
+ </div>
211
+
212
+ {/* Table */}
213
+ <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
214
+ {loading ? (
215
+ <div className="p-8 text-center text-gray-500 dark:text-gray-400">
216
+ Loading warning digests...
217
+ </div>
218
+ ) : error ? (
219
+ <div className="p-8 text-center text-red-500">
220
+ Error: {error}
221
+ </div>
222
+ ) : digests.length === 0 ? (
223
+ <div className="p-8 text-center text-gray-500 dark:text-gray-400">
224
+ No warning digests found for the selected period
225
+ </div>
226
+ ) : (
227
+ <div className="overflow-x-auto">
228
+ <table className="w-full">
229
+ <thead className="bg-gray-50 dark:bg-gray-900/50">
230
+ <tr>
231
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
232
+ Date
233
+ </th>
234
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
235
+ Script
236
+ </th>
237
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
238
+ Warning Message
239
+ </th>
240
+ <th className="px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
241
+ Count
242
+ </th>
243
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
244
+ Issue
245
+ </th>
246
+ </tr>
247
+ </thead>
248
+ <tbody>
249
+ {digests.map((digest) => (
250
+ <DigestRow
251
+ key={digest.id}
252
+ digest={digest}
253
+ expanded={expandedRow === digest.id}
254
+ onExpand={() => setExpandedRow(expandedRow === digest.id ? null : digest.id)}
255
+ />
256
+ ))}
257
+ </tbody>
258
+ </table>
259
+ </div>
260
+ )}
261
+
262
+ {/* Pagination */}
263
+ <div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 dark:border-gray-700">
264
+ <button
265
+ disabled={!filter.offset || filter.offset === 0}
266
+ onClick={() => updateFilter('offset', Math.max(0, (filter.offset || 0) - (filter.limit || 25)))}
267
+ className="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
268
+ >
269
+ Previous
270
+ </button>
271
+ <div className="flex items-center gap-3">
272
+ <select
273
+ value={filter.limit || 25}
274
+ onChange={(e) => updateFilter('limit', parseInt(e.target.value, 10))}
275
+ className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
276
+ >
277
+ <option value={25}>25 per page</option>
278
+ <option value={50}>50 per page</option>
279
+ <option value={100}>100 per page</option>
280
+ </select>
281
+ <span className="text-sm text-gray-500 dark:text-gray-400">
282
+ {total > 0
283
+ ? `${(filter.offset || 0) + 1}-${Math.min((filter.offset || 0) + (filter.limit || 25), total)} of ${total}`
284
+ : `${total} digests`}
285
+ </span>
286
+ </div>
287
+ <button
288
+ disabled={(filter.offset || 0) + (filter.limit || 25) >= total}
289
+ onClick={() => updateFilter('offset', (filter.offset || 0) + (filter.limit || 25))}
290
+ className="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
291
+ >
292
+ Next
293
+ </button>
294
+ </div>
295
+ </div>
296
+ </div>
297
+ );
298
+ }