@littlebearapps/platform-admin-sdk 2.1.0 → 2.2.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 (115) hide show
  1. package/README.md +2 -5
  2. package/dist/templates.d.ts +1 -1
  3. package/dist/templates.js +121 -3
  4. package/package.json +1 -1
  5. package/templates/full/dashboard/src/components/notifications/NotificationDropdown.tsx +130 -0
  6. package/templates/full/dashboard/src/components/notifications/NotificationItem.tsx +264 -0
  7. package/templates/full/dashboard/src/components/patterns/PatternInfoButton.tsx +60 -0
  8. package/templates/full/dashboard/src/components/reports/FeatureUsageReport.tsx +339 -0
  9. package/templates/full/dashboard/src/components/search/SearchResultGroup.tsx +46 -0
  10. package/templates/full/dashboard/src/components/search/SearchResultItem.tsx +212 -0
  11. package/templates/full/dashboard/src/pages/api/patterns/[id]/approve.ts +49 -0
  12. package/templates/full/dashboard/src/pages/api/patterns/[id]/reject.ts +50 -0
  13. package/templates/full/dashboard/src/pages/api/reports/digests/stats.ts +38 -0
  14. package/templates/full/dashboard/src/pages/api/reports/digests.ts +39 -0
  15. package/templates/full/dashboard/src/pages/api/search/reindex/[type].ts +56 -0
  16. package/templates/full/dashboard/src/pages/api/test-reports/[id].ts +102 -0
  17. package/templates/full/dashboard/src/pages/feedback.astro +365 -0
  18. package/templates/full/dashboard/src/pages/kiosk.astro +206 -0
  19. package/templates/full/dashboard/src/pages/map.astro +561 -0
  20. package/templates/full/dashboard/src/pages/revenue.astro +72 -0
  21. package/templates/full/dashboard/src/pages/tests.astro +431 -0
  22. package/templates/full/scripts/ops/audit-cost-anomaly.ts +430 -0
  23. package/templates/full/scripts/ops/verify-account-total.ts +256 -0
  24. package/templates/full/tests/integration/feedback-schema.test.ts +361 -0
  25. package/templates/full/tests/integration/r2-archive.test.ts +108 -0
  26. package/templates/shared/.github/workflows/dependabot-automerge.yml +41 -0
  27. package/templates/shared/.github/workflows/validate-controls.yml +27 -0
  28. package/templates/shared/dashboard/src/components/Breadcrumbs.astro +101 -0
  29. package/templates/shared/dashboard/src/components/EmptyState.astro +46 -0
  30. package/templates/shared/dashboard/src/components/ErrorBoundary.astro +79 -0
  31. package/templates/shared/dashboard/src/components/LoadingSkeleton.astro +105 -0
  32. package/templates/shared/dashboard/src/components/PageShell.astro +72 -0
  33. package/templates/shared/dashboard/src/components/SkipLinks.astro +22 -0
  34. package/templates/shared/dashboard/src/components/Toast.astro +170 -0
  35. package/templates/shared/dashboard/src/components/ToastContainer.astro +156 -0
  36. package/templates/shared/dashboard/src/components/costs/ProviderCostsGrid.tsx +401 -0
  37. package/templates/shared/dashboard/src/components/costs/index.ts +4 -0
  38. package/templates/shared/dashboard/src/components/overview/AlertBanner.tsx +94 -0
  39. package/templates/shared/dashboard/src/components/overview/index.ts +9 -0
  40. package/templates/shared/dashboard/src/components/resources/CostChart.tsx +170 -0
  41. package/templates/shared/dashboard/src/components/resources/ProviderCard.tsx +272 -0
  42. package/templates/shared/dashboard/src/components/resources/ProviderDetail.tsx +293 -0
  43. package/templates/shared/dashboard/src/components/settings/SettingsCard.astro +102 -0
  44. package/templates/shared/dashboard/src/components/usage/AllowanceGauge.astro +170 -0
  45. package/templates/shared/dashboard/src/components/usage/AnomalyAlerts.astro +633 -0
  46. package/templates/shared/dashboard/src/components/usage/BillingCycleCountdown.astro +192 -0
  47. package/templates/shared/dashboard/src/components/usage/BurnRateHero.astro +539 -0
  48. package/templates/shared/dashboard/src/components/usage/CircuitBreakerEventLog.astro +542 -0
  49. package/templates/shared/dashboard/src/components/usage/CircuitBreakerPanel.tsx +292 -0
  50. package/templates/shared/dashboard/src/components/usage/CircuitBreakerStatus.astro +669 -0
  51. package/templates/shared/dashboard/src/components/usage/CompactThresholdBanner.astro +531 -0
  52. package/templates/shared/dashboard/src/components/usage/ComparisonModeSelector.astro +651 -0
  53. package/templates/shared/dashboard/src/components/usage/CostBreakdownChart.astro +381 -0
  54. package/templates/shared/dashboard/src/components/usage/CostBreakdownTable.astro +210 -0
  55. package/templates/shared/dashboard/src/components/usage/CostDataTable.astro +0 -0
  56. package/templates/shared/dashboard/src/components/usage/CostDonutChart.astro +311 -0
  57. package/templates/shared/dashboard/src/components/usage/DailyCostChart.astro +632 -0
  58. package/templates/shared/dashboard/src/components/usage/ExportButton.astro +114 -0
  59. package/templates/shared/dashboard/src/components/usage/FeatureBudgetsTable.astro +872 -0
  60. package/templates/shared/dashboard/src/components/usage/FilterBar.astro +190 -0
  61. package/templates/shared/dashboard/src/components/usage/FilterToggles.astro +175 -0
  62. package/templates/shared/dashboard/src/components/usage/GitHubUsageCard.astro +537 -0
  63. package/templates/shared/dashboard/src/components/usage/OverageCostCard.astro +212 -0
  64. package/templates/shared/dashboard/src/components/usage/PlanUtilizationCard.astro +193 -0
  65. package/templates/shared/dashboard/src/components/usage/ProjectCard.astro +640 -0
  66. package/templates/shared/dashboard/src/components/usage/ProjectCardsGrid.astro +272 -0
  67. package/templates/shared/dashboard/src/components/usage/ResourceSearch.astro +279 -0
  68. package/templates/shared/dashboard/src/components/usage/ServiceUtilizationList.astro +604 -0
  69. package/templates/shared/dashboard/src/components/usage/SparklineCard.astro +399 -0
  70. package/templates/shared/dashboard/src/components/usage/StatsHero.astro +600 -0
  71. package/templates/shared/dashboard/src/components/usage/TableFilters.astro +1033 -0
  72. package/templates/shared/dashboard/src/components/usage/ThresholdAlert.astro +271 -0
  73. package/templates/shared/dashboard/src/components/usage/ThresholdSettings.astro +618 -0
  74. package/templates/shared/dashboard/src/components/usage/TopSpenderCard.astro +170 -0
  75. package/templates/shared/dashboard/src/components/usage/UnifiedResourceTable.astro +1737 -0
  76. package/templates/shared/dashboard/src/components/usage/UsageCard.astro +135 -0
  77. package/templates/shared/dashboard/src/components/usage/UsageHealthBanner.astro +387 -0
  78. package/templates/shared/dashboard/src/components/usage/UtilizationBar.astro +159 -0
  79. package/templates/shared/dashboard/src/components/usage/WorkersBreakdownTable.astro +659 -0
  80. package/templates/shared/dashboard/src/components/usage/daily/CostChart.astro +461 -0
  81. package/templates/shared/dashboard/src/components/usage/daily/CostTable.astro +946 -0
  82. package/templates/shared/dashboard/src/components/usage/daily/DailyOverview.astro +1079 -0
  83. package/templates/shared/dashboard/src/components/usage/design-tokens.ts +187 -0
  84. package/templates/shared/dashboard/src/components/usage/filters/InlineDateRange.astro +285 -0
  85. package/templates/shared/dashboard/src/components/usage/filters/PeriodButtons.astro +157 -0
  86. package/templates/shared/dashboard/src/components/usage/filters/ProjectSelect.astro +284 -0
  87. package/templates/shared/dashboard/src/components/usage/scripts/ai-tab-controller.ts +419 -0
  88. package/templates/shared/dashboard/src/components/usage/scripts/constants.ts +60 -0
  89. package/templates/shared/dashboard/src/components/usage/scripts/formatters.ts +62 -0
  90. package/templates/shared/dashboard/src/components/usage/scripts/overview-controller.ts +1633 -0
  91. package/templates/shared/dashboard/src/components/usage/scripts/resource-table-builder.ts +294 -0
  92. package/templates/shared/dashboard/src/components/usage/scripts/tabs-filters-controller.ts +464 -0
  93. package/templates/shared/dashboard/src/components/usage/state/index.ts +55 -0
  94. package/templates/shared/dashboard/src/components/usage/state/usageActions.ts +439 -0
  95. package/templates/shared/dashboard/src/components/usage/state/usageStore.ts +376 -0
  96. package/templates/shared/dashboard/src/components/usage/types.ts +283 -0
  97. package/templates/shared/dashboard/src/components/usage/usage-colors.ts +292 -0
  98. package/templates/shared/dashboard/src/pages/api/usage/ai-models.ts +235 -0
  99. package/templates/shared/dashboard/src/pages/api/usage/billing-context.ts +296 -0
  100. package/templates/shared/scripts/test-telemetry-flow.ts +464 -0
  101. package/templates/shared/tests/e2e/usage-export.test.ts +784 -0
  102. package/templates/shared/tests/e2e/usage-mobile.test.ts +531 -0
  103. package/templates/standard/dashboard/src/components/errors/PriorityBadge.astro +27 -0
  104. package/templates/standard/dashboard/src/components/infrastructure/HealthchecksStatus.tsx +293 -0
  105. package/templates/standard/dashboard/src/components/infrastructure/InfrastructureTabs.tsx +268 -0
  106. package/templates/standard/dashboard/src/pages/analytics.astro +64 -0
  107. package/templates/standard/dashboard/src/pages/api/infrastructure/alerts.ts +85 -0
  108. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks/[id]/flips.ts +110 -0
  109. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks.ts +101 -0
  110. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime/[id]/response-times.ts +121 -0
  111. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime.ts +89 -0
  112. package/templates/standard/dashboard/src/pages/api/test/service-auth.ts +178 -0
  113. package/templates/standard/tests/integration/connectors.test.ts +241 -0
  114. package/templates/standard/tests/integration/github-monitor.test.ts +143 -0
  115. package/templates/standard/tests/integration/ingestion.test.ts +211 -0
@@ -0,0 +1,1033 @@
1
+ ---
2
+ /**
3
+ * TableFilters.astro
4
+ *
5
+ * Enhanced filtering controls for the usage dashboard.
6
+ * Features: search, resource type multi-select, project filter, status filter,
7
+ * sort controls, and URL parameter persistence.
8
+ */
9
+
10
+ interface Props {
11
+ projects?: string[];
12
+ resourceTypes?: string[];
13
+ initialFilters?: {
14
+ search?: string;
15
+ types?: string[];
16
+ project?: string;
17
+ status?: string;
18
+ sort?: string;
19
+ sortDir?: 'asc' | 'desc';
20
+ };
21
+ }
22
+
23
+ const {
24
+ projects = [],
25
+ resourceTypes = [
26
+ 'Workers',
27
+ 'D1',
28
+ 'KV',
29
+ 'R2',
30
+ 'Pages',
31
+ 'Vectorize',
32
+ 'AI Gateway',
33
+ 'Queues',
34
+ 'Workflows',
35
+ 'Durable Objects',
36
+ ],
37
+ initialFilters = {},
38
+ } = Astro.props;
39
+
40
+ // Parse URL params for initial state
41
+ const url = new URL(Astro.request.url);
42
+ const urlSearch = url.searchParams.get('search') || initialFilters.search || '';
43
+ const urlTypes =
44
+ url.searchParams.get('types')?.split(',').filter(Boolean) || initialFilters.types || [];
45
+ const urlProject = url.searchParams.get('project') || initialFilters.project || '';
46
+ const urlStatus = url.searchParams.get('status') || initialFilters.status || '';
47
+ const urlSort = url.searchParams.get('sort') || initialFilters.sort || 'cost';
48
+ const urlSortDir =
49
+ (url.searchParams.get('sortDir') as 'asc' | 'desc') || initialFilters.sortDir || 'desc';
50
+ ---
51
+
52
+ <div class="table-filters" data-filters>
53
+ <!-- Search Input -->
54
+ <div class="filter-group search-group">
55
+ <label for="filter-search" class="filter-label">Search</label>
56
+ <div class="search-input-wrapper">
57
+ <svg class="search-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
58
+ <path
59
+ d="M7 12A5 5 0 1 0 7 2a5 5 0 0 0 0 10zM14 14l-3.5-3.5"
60
+ stroke="currentColor"
61
+ stroke-width="1.5"
62
+ stroke-linecap="round"
63
+ stroke-linejoin="round"></path>
64
+ </svg>
65
+ <input
66
+ type="text"
67
+ id="filter-search"
68
+ class="filter-input search-input"
69
+ placeholder="Search resources..."
70
+ value={urlSearch}
71
+ data-filter-search
72
+ />
73
+ <button
74
+ type="button"
75
+ class="clear-search"
76
+ data-clear-search
77
+ aria-label="Clear search"
78
+ style={urlSearch ? '' : 'display: none'}
79
+ >
80
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
81
+ <path
82
+ d="M10.5 3.5L3.5 10.5M3.5 3.5l7 7"
83
+ stroke="currentColor"
84
+ stroke-width="1.5"
85
+ stroke-linecap="round"></path>
86
+ </svg>
87
+ </button>
88
+ </div>
89
+ </div>
90
+
91
+ <!-- Resource Type Multi-Select -->
92
+ <div class="filter-group">
93
+ <label class="filter-label">Resource Types</label>
94
+ <div class="multi-select" data-multi-select="types">
95
+ <button type="button" class="multi-select-trigger" data-trigger>
96
+ <span class="multi-select-value" data-value>
97
+ {
98
+ urlTypes.length === 0
99
+ ? 'All Types'
100
+ : urlTypes.length === 1
101
+ ? urlTypes[0]
102
+ : `${urlTypes.length} selected`
103
+ }
104
+ </span>
105
+ <svg class="dropdown-chevron" width="12" height="12" viewBox="0 0 12 12" fill="none">
106
+ <path
107
+ d="M3 4.5l3 3 3-3"
108
+ stroke="currentColor"
109
+ stroke-width="1.5"
110
+ stroke-linecap="round"
111
+ stroke-linejoin="round"></path>
112
+ </svg>
113
+ </button>
114
+ <div class="multi-select-dropdown" data-dropdown>
115
+ <div class="dropdown-header">
116
+ <button type="button" class="select-action" data-select-all>Select All</button>
117
+ <button type="button" class="select-action" data-clear-all>Clear</button>
118
+ </div>
119
+ <div class="dropdown-options">
120
+ {
121
+ resourceTypes.map((type) => (
122
+ <label class="dropdown-option">
123
+ <input
124
+ type="checkbox"
125
+ value={type}
126
+ checked={urlTypes.includes(type)}
127
+ data-type-checkbox
128
+ />
129
+ <span class="checkbox-custom" />
130
+ <span class="option-label">{type}</span>
131
+ </label>
132
+ ))
133
+ }
134
+ </div>
135
+ </div>
136
+ </div>
137
+ </div>
138
+
139
+ <!-- Project Filter -->
140
+ <div class="filter-group">
141
+ <label for="filter-project" class="filter-label">Project</label>
142
+ <div class="select-wrapper">
143
+ <select id="filter-project" class="filter-select" data-filter-project>
144
+ <option value="">All Projects</option>
145
+ {
146
+ projects.map((project) => (
147
+ <option value={project} selected={urlProject === project}>
148
+ {project}
149
+ </option>
150
+ ))
151
+ }
152
+ </select>
153
+ <svg class="select-chevron" width="12" height="12" viewBox="0 0 12 12" fill="none">
154
+ <path
155
+ d="M3 4.5l3 3 3-3"
156
+ stroke="currentColor"
157
+ stroke-width="1.5"
158
+ stroke-linecap="round"
159
+ stroke-linejoin="round"></path>
160
+ </svg>
161
+ </div>
162
+ </div>
163
+
164
+ <!-- Status Filter -->
165
+ <div class="filter-group">
166
+ <label for="filter-status" class="filter-label">Status</label>
167
+ <div class="status-buttons" data-status-filter>
168
+ <button type="button" class={`status-btn ${urlStatus === '' ? 'active' : ''}`} data-status=""
169
+ >All</button
170
+ >
171
+ <button
172
+ type="button"
173
+ class={`status-btn status-ok ${urlStatus === 'ok' ? 'active' : ''}`}
174
+ data-status="ok"
175
+ >
176
+ <span class="status-dot ok"></span>
177
+ OK
178
+ </button>
179
+ <button
180
+ type="button"
181
+ class={`status-btn status-warning ${urlStatus === 'warning' ? 'active' : ''}`}
182
+ data-status="warning"
183
+ >
184
+ <span class="status-dot warning"></span>
185
+ Warning
186
+ </button>
187
+ <button
188
+ type="button"
189
+ class={`status-btn status-critical ${urlStatus === 'critical' ? 'active' : ''}`}
190
+ data-status="critical"
191
+ >
192
+ <span class="status-dot critical"></span>
193
+ Critical
194
+ </button>
195
+ </div>
196
+ </div>
197
+
198
+ <!-- Sort Controls -->
199
+ <div class="filter-group sort-group">
200
+ <label for="filter-sort" class="filter-label">Sort By</label>
201
+ <div class="sort-controls">
202
+ <div class="select-wrapper">
203
+ <select id="filter-sort" class="filter-select" data-filter-sort>
204
+ <option value="cost" selected={urlSort === 'cost'}>Cost</option>
205
+ <option value="name" selected={urlSort === 'name'}>Name</option>
206
+ <option value="usage" selected={urlSort === 'usage'}>Usage</option>
207
+ <option value="requests" selected={urlSort === 'requests'}>Requests</option>
208
+ <option value="status" selected={urlSort === 'status'}>Status</option>
209
+ </select>
210
+ <svg class="select-chevron" width="12" height="12" viewBox="0 0 12 12" fill="none">
211
+ <path
212
+ d="M3 4.5l3 3 3-3"
213
+ stroke="currentColor"
214
+ stroke-width="1.5"
215
+ stroke-linecap="round"
216
+ stroke-linejoin="round"></path>
217
+ </svg>
218
+ </div>
219
+ <button
220
+ type="button"
221
+ class="sort-direction"
222
+ data-sort-direction={urlSortDir}
223
+ aria-label={`Sort ${urlSortDir === 'asc' ? 'ascending' : 'descending'}`}
224
+ >
225
+ <!-- Ascending arrow (shown when sortDir is asc) -->
226
+ <svg
227
+ class="sort-arrow sort-asc"
228
+ width="14"
229
+ height="14"
230
+ viewBox="0 0 14 14"
231
+ fill="none"
232
+ style={urlSortDir === 'asc' ? '' : 'display: none'}
233
+ >
234
+ <path
235
+ d="M7 11V3M7 3l3 3M7 3 4 6"
236
+ stroke="currentColor"
237
+ stroke-width="1.5"
238
+ stroke-linecap="round"
239
+ stroke-linejoin="round"></path>
240
+ </svg>
241
+ <!-- Descending arrow (shown when sortDir is desc) -->
242
+ <svg
243
+ class="sort-arrow sort-desc"
244
+ width="14"
245
+ height="14"
246
+ viewBox="0 0 14 14"
247
+ fill="none"
248
+ style={urlSortDir === 'desc' ? '' : 'display: none'}
249
+ >
250
+ <path
251
+ d="M7 3v8M7 11l3-3M7 11 4 8"
252
+ stroke="currentColor"
253
+ stroke-width="1.5"
254
+ stroke-linecap="round"
255
+ stroke-linejoin="round"></path>
256
+ </svg>
257
+ </button>
258
+ </div>
259
+ </div>
260
+
261
+ <!-- Clear All -->
262
+ <div class="filter-group clear-group">
263
+ <button type="button" class="clear-all-btn" data-clear-all-filters>
264
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
265
+ <path
266
+ d="M10.5 3.5L3.5 10.5M3.5 3.5l7 7"
267
+ stroke="currentColor"
268
+ stroke-width="1.5"
269
+ stroke-linecap="round"></path>
270
+ </svg>
271
+ Clear All
272
+ </button>
273
+ </div>
274
+
275
+ <!-- Active Filters Display -->
276
+ <div class="active-filters" data-active-filters style="display: none">
277
+ <span class="active-filters-label">Active:</span>
278
+ <div class="filter-tags" data-filter-tags></div>
279
+ </div>
280
+ </div>
281
+
282
+ <style>
283
+ .table-filters {
284
+ display: flex;
285
+ flex-wrap: wrap;
286
+ gap: var(--usage-spacing-md, 1rem);
287
+ align-items: flex-end;
288
+ padding: var(--usage-spacing-md, 1rem);
289
+ background: var(--usage-bg-secondary);
290
+ border: 1px solid var(--usage-border-default);
291
+ border-radius: var(--usage-radius-lg, 8px);
292
+ margin-bottom: var(--usage-spacing-lg, 1.5rem);
293
+ }
294
+
295
+ .filter-group {
296
+ display: flex;
297
+ flex-direction: column;
298
+ gap: var(--usage-spacing-xs, 0.25rem);
299
+ }
300
+
301
+ .filter-label {
302
+ font-family: var(--usage-font-sans);
303
+ font-size: 0.75rem;
304
+ font-weight: 500;
305
+ color: var(--usage-text-secondary);
306
+ text-transform: uppercase;
307
+ letter-spacing: 0.05em;
308
+ }
309
+
310
+ /* Search Input */
311
+ .search-group {
312
+ flex: 1;
313
+ min-width: 200px;
314
+ max-width: 320px;
315
+ }
316
+
317
+ .search-input-wrapper {
318
+ position: relative;
319
+ display: flex;
320
+ align-items: center;
321
+ }
322
+
323
+ .search-icon {
324
+ position: absolute;
325
+ left: 0.75rem;
326
+ color: var(--usage-text-muted);
327
+ pointer-events: none;
328
+ }
329
+
330
+ .search-input {
331
+ width: 100%;
332
+ padding: 0.5rem 2rem 0.5rem 2.25rem;
333
+ font-family: var(--usage-font-sans);
334
+ font-size: 0.875rem;
335
+ color: var(--usage-text-primary);
336
+ background: var(--usage-bg-primary);
337
+ border: 1px solid var(--usage-border-default);
338
+ border-radius: var(--usage-radius-md, 6px);
339
+ transition:
340
+ border-color var(--usage-transition-fast),
341
+ box-shadow var(--usage-transition-fast);
342
+ }
343
+
344
+ .search-input:focus {
345
+ outline: none;
346
+ border-color: var(--usage-accent-blue);
347
+ box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.15);
348
+ }
349
+
350
+ .search-input::placeholder {
351
+ color: var(--usage-text-muted);
352
+ }
353
+
354
+ .clear-search {
355
+ position: absolute;
356
+ right: 0.5rem;
357
+ padding: 0.25rem;
358
+ background: none;
359
+ border: none;
360
+ color: var(--usage-text-muted);
361
+ cursor: pointer;
362
+ border-radius: var(--usage-radius-sm, 4px);
363
+ transition:
364
+ color var(--usage-transition-fast),
365
+ background var(--usage-transition-fast);
366
+ }
367
+
368
+ .clear-search:hover {
369
+ color: var(--usage-text-primary);
370
+ background: var(--usage-bg-hover);
371
+ }
372
+
373
+ /* Multi-Select Dropdown */
374
+ .multi-select {
375
+ position: relative;
376
+ }
377
+
378
+ .multi-select-trigger {
379
+ display: flex;
380
+ align-items: center;
381
+ justify-content: space-between;
382
+ gap: 0.5rem;
383
+ min-width: 140px;
384
+ padding: 0.5rem 0.75rem;
385
+ font-family: var(--usage-font-sans);
386
+ font-size: 0.875rem;
387
+ color: var(--usage-text-primary);
388
+ background: var(--usage-bg-primary);
389
+ border: 1px solid var(--usage-border-default);
390
+ border-radius: var(--usage-radius-md, 6px);
391
+ cursor: pointer;
392
+ transition: border-color var(--usage-transition-fast);
393
+ }
394
+
395
+ .multi-select-trigger:hover {
396
+ border-color: var(--usage-border-hover);
397
+ }
398
+
399
+ .multi-select-trigger:focus {
400
+ outline: none;
401
+ border-color: var(--usage-accent-blue);
402
+ box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.15);
403
+ }
404
+
405
+ .dropdown-chevron {
406
+ color: var(--usage-text-muted);
407
+ transition: transform var(--usage-transition-fast);
408
+ }
409
+
410
+ .multi-select.open .dropdown-chevron {
411
+ transform: rotate(180deg);
412
+ }
413
+
414
+ .multi-select-dropdown {
415
+ position: absolute;
416
+ top: calc(100% + 4px);
417
+ left: 0;
418
+ z-index: 100;
419
+ min-width: 200px;
420
+ background: var(--usage-bg-primary);
421
+ border: 1px solid var(--usage-border-default);
422
+ border-radius: var(--usage-radius-md, 6px);
423
+ box-shadow: var(--usage-shadow-lg);
424
+ opacity: 0;
425
+ visibility: hidden;
426
+ transform: translateY(-8px);
427
+ transition:
428
+ opacity var(--usage-transition-fast),
429
+ transform var(--usage-transition-fast),
430
+ visibility var(--usage-transition-fast);
431
+ }
432
+
433
+ .multi-select.open .multi-select-dropdown {
434
+ opacity: 1;
435
+ visibility: visible;
436
+ transform: translateY(0);
437
+ }
438
+
439
+ .dropdown-header {
440
+ display: flex;
441
+ justify-content: space-between;
442
+ padding: 0.5rem 0.75rem;
443
+ border-bottom: 1px solid var(--usage-border-subtle);
444
+ }
445
+
446
+ .select-action {
447
+ font-family: var(--usage-font-sans);
448
+ font-size: 0.75rem;
449
+ color: var(--usage-accent-blue);
450
+ background: none;
451
+ border: none;
452
+ cursor: pointer;
453
+ padding: 0;
454
+ }
455
+
456
+ .select-action:hover {
457
+ text-decoration: underline;
458
+ }
459
+
460
+ .dropdown-options {
461
+ max-height: 240px;
462
+ overflow-y: auto;
463
+ padding: 0.5rem 0;
464
+ }
465
+
466
+ .dropdown-option {
467
+ display: flex;
468
+ align-items: center;
469
+ gap: 0.5rem;
470
+ padding: 0.5rem 0.75rem;
471
+ cursor: pointer;
472
+ transition: background var(--usage-transition-fast);
473
+ }
474
+
475
+ .dropdown-option:hover {
476
+ background: var(--usage-bg-hover);
477
+ }
478
+
479
+ .dropdown-option input[type='checkbox'] {
480
+ position: absolute;
481
+ opacity: 0;
482
+ width: 0;
483
+ height: 0;
484
+ }
485
+
486
+ .checkbox-custom {
487
+ width: 16px;
488
+ height: 16px;
489
+ border: 1.5px solid var(--usage-border-default);
490
+ border-radius: 3px;
491
+ background: var(--usage-bg-primary);
492
+ transition: all var(--usage-transition-fast);
493
+ position: relative;
494
+ }
495
+
496
+ .dropdown-option input:checked + .checkbox-custom {
497
+ background: var(--usage-accent-blue);
498
+ border-color: var(--usage-accent-blue);
499
+ }
500
+
501
+ .dropdown-option input:checked + .checkbox-custom::after {
502
+ content: '';
503
+ position: absolute;
504
+ left: 4px;
505
+ top: 1px;
506
+ width: 5px;
507
+ height: 9px;
508
+ border: solid white;
509
+ border-width: 0 2px 2px 0;
510
+ transform: rotate(45deg);
511
+ }
512
+
513
+ .option-label {
514
+ font-family: var(--usage-font-sans);
515
+ font-size: 0.875rem;
516
+ color: var(--usage-text-primary);
517
+ }
518
+
519
+ /* Select Wrapper */
520
+ .select-wrapper {
521
+ position: relative;
522
+ display: inline-flex;
523
+ align-items: center;
524
+ }
525
+
526
+ .filter-select {
527
+ appearance: none;
528
+ padding: 0.5rem 2rem 0.5rem 0.75rem;
529
+ font-family: var(--usage-font-sans);
530
+ font-size: 0.875rem;
531
+ color: var(--usage-text-primary);
532
+ background: var(--usage-bg-primary);
533
+ border: 1px solid var(--usage-border-default);
534
+ border-radius: var(--usage-radius-md, 6px);
535
+ cursor: pointer;
536
+ transition: border-color var(--usage-transition-fast);
537
+ }
538
+
539
+ .filter-select:hover {
540
+ border-color: var(--usage-border-hover);
541
+ }
542
+
543
+ .filter-select:focus {
544
+ outline: none;
545
+ border-color: var(--usage-accent-blue);
546
+ box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.15);
547
+ }
548
+
549
+ .select-chevron {
550
+ position: absolute;
551
+ right: 0.75rem;
552
+ color: var(--usage-text-muted);
553
+ pointer-events: none;
554
+ }
555
+
556
+ /* Status Buttons */
557
+ .status-buttons {
558
+ display: flex;
559
+ gap: 0.25rem;
560
+ background: var(--usage-bg-tertiary);
561
+ padding: 0.25rem;
562
+ border-radius: var(--usage-radius-md, 6px);
563
+ }
564
+
565
+ .status-btn {
566
+ display: flex;
567
+ align-items: center;
568
+ gap: 0.375rem;
569
+ padding: 0.375rem 0.625rem;
570
+ font-family: var(--usage-font-sans);
571
+ font-size: 0.8125rem;
572
+ color: var(--usage-text-secondary);
573
+ background: transparent;
574
+ border: none;
575
+ border-radius: var(--usage-radius-sm, 4px);
576
+ cursor: pointer;
577
+ transition: all var(--usage-transition-fast);
578
+ }
579
+
580
+ .status-btn:hover {
581
+ color: var(--usage-text-primary);
582
+ background: var(--usage-bg-hover);
583
+ }
584
+
585
+ .status-btn.active {
586
+ color: var(--usage-text-primary);
587
+ background: var(--usage-bg-primary);
588
+ box-shadow: var(--usage-shadow-sm);
589
+ }
590
+
591
+ .status-dot {
592
+ width: 8px;
593
+ height: 8px;
594
+ border-radius: 50%;
595
+ }
596
+
597
+ .status-dot.ok {
598
+ background: var(--usage-status-ok);
599
+ }
600
+
601
+ .status-dot.warning {
602
+ background: var(--usage-status-warning);
603
+ }
604
+
605
+ .status-dot.critical {
606
+ background: var(--usage-status-critical);
607
+ }
608
+
609
+ /* Sort Controls */
610
+ .sort-controls {
611
+ display: flex;
612
+ gap: 0.25rem;
613
+ }
614
+
615
+ .sort-direction {
616
+ display: flex;
617
+ align-items: center;
618
+ justify-content: center;
619
+ width: 34px;
620
+ height: 34px;
621
+ background: var(--usage-bg-primary);
622
+ border: 1px solid var(--usage-border-default);
623
+ border-radius: var(--usage-radius-md, 6px);
624
+ color: var(--usage-text-secondary);
625
+ cursor: pointer;
626
+ transition: all var(--usage-transition-fast);
627
+ }
628
+
629
+ .sort-direction:hover {
630
+ border-color: var(--usage-border-hover);
631
+ color: var(--usage-text-primary);
632
+ }
633
+
634
+ .sort-direction:focus {
635
+ outline: none;
636
+ border-color: var(--usage-accent-blue);
637
+ box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.15);
638
+ }
639
+
640
+ /* Clear All Button */
641
+ .clear-group {
642
+ margin-left: auto;
643
+ }
644
+
645
+ .clear-all-btn {
646
+ display: flex;
647
+ align-items: center;
648
+ gap: 0.375rem;
649
+ padding: 0.5rem 0.75rem;
650
+ font-family: var(--usage-font-sans);
651
+ font-size: 0.875rem;
652
+ color: var(--usage-text-secondary);
653
+ background: transparent;
654
+ border: 1px solid transparent;
655
+ border-radius: var(--usage-radius-md, 6px);
656
+ cursor: pointer;
657
+ transition: all var(--usage-transition-fast);
658
+ }
659
+
660
+ .clear-all-btn:hover {
661
+ color: var(--usage-accent-red);
662
+ background: var(--usage-status-critical-bg);
663
+ border-color: var(--usage-accent-red);
664
+ }
665
+
666
+ /* Active Filters */
667
+ .active-filters {
668
+ width: 100%;
669
+ display: flex;
670
+ align-items: center;
671
+ gap: 0.5rem;
672
+ padding-top: var(--usage-spacing-sm, 0.5rem);
673
+ border-top: 1px solid var(--usage-border-subtle);
674
+ margin-top: var(--usage-spacing-xs, 0.25rem);
675
+ }
676
+
677
+ .active-filters-label {
678
+ font-family: var(--usage-font-sans);
679
+ font-size: 0.75rem;
680
+ color: var(--usage-text-muted);
681
+ }
682
+
683
+ .filter-tags {
684
+ display: flex;
685
+ flex-wrap: wrap;
686
+ gap: 0.375rem;
687
+ }
688
+
689
+ .filter-tag {
690
+ display: inline-flex;
691
+ align-items: center;
692
+ gap: 0.25rem;
693
+ padding: 0.25rem 0.5rem;
694
+ font-family: var(--usage-font-sans);
695
+ font-size: 0.75rem;
696
+ color: var(--usage-accent-blue);
697
+ background: rgba(9, 105, 218, 0.1);
698
+ border-radius: var(--usage-radius-sm, 4px);
699
+ }
700
+
701
+ .filter-tag button {
702
+ display: flex;
703
+ padding: 0;
704
+ background: none;
705
+ border: none;
706
+ color: inherit;
707
+ cursor: pointer;
708
+ opacity: 0.7;
709
+ transition: opacity var(--usage-transition-fast);
710
+ }
711
+
712
+ .filter-tag button:hover {
713
+ opacity: 1;
714
+ }
715
+
716
+ /* Responsive */
717
+ @media (max-width: 768px) {
718
+ .table-filters {
719
+ flex-direction: column;
720
+ align-items: stretch;
721
+ }
722
+
723
+ .filter-group {
724
+ width: 100%;
725
+ }
726
+
727
+ .search-group {
728
+ max-width: none;
729
+ }
730
+
731
+ .clear-group {
732
+ margin-left: 0;
733
+ }
734
+
735
+ .status-buttons {
736
+ flex-wrap: wrap;
737
+ }
738
+ }
739
+ </style>
740
+
741
+ <script>
742
+ // Initialize filters
743
+ const filtersContainer = document.querySelector('[data-filters]') as HTMLElement;
744
+ if (filtersContainer) {
745
+ // State
746
+ let debounceTimer: ReturnType<typeof setTimeout>;
747
+
748
+ // Elements
749
+ const searchInput = filtersContainer.querySelector('[data-filter-search]') as HTMLInputElement;
750
+ const clearSearchBtn = filtersContainer.querySelector(
751
+ '[data-clear-search]'
752
+ ) as HTMLButtonElement;
753
+ const multiSelect = filtersContainer.querySelector(
754
+ '[data-multi-select="types"]'
755
+ ) as HTMLElement;
756
+ const multiSelectTrigger = multiSelect?.querySelector('[data-trigger]') as HTMLButtonElement;
757
+ const multiSelectDropdown = multiSelect?.querySelector('[data-dropdown]') as HTMLElement;
758
+ const multiSelectValue = multiSelect?.querySelector('[data-value]') as HTMLElement;
759
+ const typeCheckboxes = filtersContainer.querySelectorAll(
760
+ '[data-type-checkbox]'
761
+ ) as NodeListOf<HTMLInputElement>;
762
+ const selectAllBtn = multiSelect?.querySelector('[data-select-all]') as HTMLButtonElement;
763
+ const clearAllTypesBtn = multiSelect?.querySelector('[data-clear-all]') as HTMLButtonElement;
764
+ const projectSelect = filtersContainer.querySelector(
765
+ '[data-filter-project]'
766
+ ) as HTMLSelectElement;
767
+ const statusButtons = filtersContainer.querySelectorAll(
768
+ '[data-status]'
769
+ ) as NodeListOf<HTMLButtonElement>;
770
+ const sortSelect = filtersContainer.querySelector('[data-filter-sort]') as HTMLSelectElement;
771
+ const sortDirBtn = filtersContainer.querySelector('[data-sort-direction]') as HTMLButtonElement;
772
+ const clearAllFiltersBtn = filtersContainer.querySelector(
773
+ '[data-clear-all-filters]'
774
+ ) as HTMLButtonElement;
775
+ const activeFiltersContainer = filtersContainer.querySelector(
776
+ '[data-active-filters]'
777
+ ) as HTMLElement;
778
+ const filterTagsContainer = filtersContainer.querySelector('[data-filter-tags]') as HTMLElement;
779
+
780
+ // Get current filter state
781
+ function getFilters() {
782
+ const selectedTypes: string[] = [];
783
+ typeCheckboxes.forEach((cb) => {
784
+ if (cb.checked) selectedTypes.push(cb.value);
785
+ });
786
+
787
+ const activeStatus = filtersContainer.querySelector(
788
+ '.status-btn.active'
789
+ ) as HTMLButtonElement;
790
+
791
+ return {
792
+ search: searchInput?.value || '',
793
+ types: selectedTypes,
794
+ project: projectSelect?.value || '',
795
+ status: activeStatus?.dataset.status || '',
796
+ sort: sortSelect?.value || 'cost',
797
+ sortDir: (sortDirBtn?.dataset.sortDirection || 'desc') as 'asc' | 'desc',
798
+ };
799
+ }
800
+
801
+ // Update URL params
802
+ function updateURL(filters: ReturnType<typeof getFilters>) {
803
+ const url = new URL(window.location.href);
804
+
805
+ if (filters.search) url.searchParams.set('search', filters.search);
806
+ else url.searchParams.delete('search');
807
+
808
+ if (filters.types.length > 0) url.searchParams.set('types', filters.types.join(','));
809
+ else url.searchParams.delete('types');
810
+
811
+ if (filters.project) url.searchParams.set('project', filters.project);
812
+ else url.searchParams.delete('project');
813
+
814
+ if (filters.status) url.searchParams.set('status', filters.status);
815
+ else url.searchParams.delete('status');
816
+
817
+ if (filters.sort !== 'cost') url.searchParams.set('sort', filters.sort);
818
+ else url.searchParams.delete('sort');
819
+
820
+ if (filters.sortDir !== 'desc') url.searchParams.set('sortDir', filters.sortDir);
821
+ else url.searchParams.delete('sortDir');
822
+
823
+ window.history.replaceState({}, '', url.toString());
824
+ }
825
+
826
+ // Dispatch filter change event
827
+ function emitFilterChange() {
828
+ const filters = getFilters();
829
+ updateURL(filters);
830
+ updateActiveFilters(filters);
831
+
832
+ window.dispatchEvent(
833
+ new CustomEvent('usage:filters-changed', {
834
+ detail: filters,
835
+ })
836
+ );
837
+ }
838
+
839
+ // Update active filters display
840
+ function updateActiveFilters(filters: ReturnType<typeof getFilters>) {
841
+ const tags: string[] = [];
842
+
843
+ if (filters.search) tags.push(`Search: "${filters.search}"`);
844
+ if (filters.types.length > 0 && filters.types.length < typeCheckboxes.length) {
845
+ tags.push(`Types: ${filters.types.join(', ')}`);
846
+ }
847
+ if (filters.project) tags.push(`Project: ${filters.project}`);
848
+ if (filters.status) tags.push(`Status: ${filters.status}`);
849
+
850
+ if (tags.length > 0) {
851
+ activeFiltersContainer.style.display = '';
852
+ filterTagsContainer.textContent = ''; // Clear existing
853
+
854
+ tags.forEach((tag) => {
855
+ const tagEl = document.createElement('span');
856
+ tagEl.className = 'filter-tag';
857
+
858
+ const textSpan = document.createElement('span');
859
+ textSpan.textContent = tag;
860
+ tagEl.appendChild(textSpan);
861
+
862
+ const removeBtn = document.createElement('button');
863
+ removeBtn.type = 'button';
864
+ removeBtn.setAttribute('aria-label', `Remove ${tag}`);
865
+
866
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
867
+ svg.setAttribute('width', '10');
868
+ svg.setAttribute('height', '10');
869
+ svg.setAttribute('viewBox', '0 0 10 10');
870
+ svg.setAttribute('fill', 'none');
871
+
872
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
873
+ path.setAttribute('d', 'M7.5 2.5L2.5 7.5M2.5 2.5l5 5');
874
+ path.setAttribute('stroke', 'currentColor');
875
+ path.setAttribute('stroke-width', '1.5');
876
+ path.setAttribute('stroke-linecap', 'round');
877
+
878
+ svg.appendChild(path);
879
+ removeBtn.appendChild(svg);
880
+ tagEl.appendChild(removeBtn);
881
+
882
+ filterTagsContainer.appendChild(tagEl);
883
+ });
884
+ } else {
885
+ activeFiltersContainer.style.display = 'none';
886
+ }
887
+ }
888
+
889
+ // Update multi-select display text
890
+ function updateMultiSelectValue() {
891
+ const selected: string[] = [];
892
+ typeCheckboxes.forEach((cb) => {
893
+ if (cb.checked) selected.push(cb.value);
894
+ });
895
+
896
+ if (selected.length === 0) {
897
+ multiSelectValue.textContent = 'All Types';
898
+ } else if (selected.length === 1) {
899
+ multiSelectValue.textContent = selected[0];
900
+ } else if (selected.length === typeCheckboxes.length) {
901
+ multiSelectValue.textContent = 'All Types';
902
+ } else {
903
+ multiSelectValue.textContent = `${selected.length} selected`;
904
+ }
905
+ }
906
+
907
+ // Search input with debounce
908
+ searchInput?.addEventListener('input', () => {
909
+ clearTimeout(debounceTimer);
910
+ if (clearSearchBtn) {
911
+ clearSearchBtn.style.display = searchInput.value ? '' : 'none';
912
+ }
913
+ debounceTimer = setTimeout(emitFilterChange, 300);
914
+ });
915
+
916
+ clearSearchBtn?.addEventListener('click', () => {
917
+ searchInput.value = '';
918
+ clearSearchBtn.style.display = 'none';
919
+ emitFilterChange();
920
+ });
921
+
922
+ // Multi-select dropdown toggle
923
+ multiSelectTrigger?.addEventListener('click', () => {
924
+ multiSelect.classList.toggle('open');
925
+ });
926
+
927
+ // Close dropdown when clicking outside
928
+ document.addEventListener('click', (e) => {
929
+ if (multiSelect && !multiSelect.contains(e.target as Node)) {
930
+ multiSelect.classList.remove('open');
931
+ }
932
+ });
933
+
934
+ // Type checkboxes
935
+ typeCheckboxes.forEach((cb) => {
936
+ cb.addEventListener('change', () => {
937
+ updateMultiSelectValue();
938
+ emitFilterChange();
939
+ });
940
+ });
941
+
942
+ selectAllBtn?.addEventListener('click', () => {
943
+ typeCheckboxes.forEach((cb) => (cb.checked = true));
944
+ updateMultiSelectValue();
945
+ emitFilterChange();
946
+ });
947
+
948
+ clearAllTypesBtn?.addEventListener('click', () => {
949
+ typeCheckboxes.forEach((cb) => (cb.checked = false));
950
+ updateMultiSelectValue();
951
+ emitFilterChange();
952
+ });
953
+
954
+ // Project select
955
+ projectSelect?.addEventListener('change', emitFilterChange);
956
+
957
+ // Status buttons
958
+ statusButtons.forEach((btn) => {
959
+ btn.addEventListener('click', () => {
960
+ statusButtons.forEach((b) => b.classList.remove('active'));
961
+ btn.classList.add('active');
962
+ emitFilterChange();
963
+ });
964
+ });
965
+
966
+ // Sort select
967
+ sortSelect?.addEventListener('change', emitFilterChange);
968
+
969
+ // Sort direction toggle - using visibility toggle instead of innerHTML
970
+ sortDirBtn?.addEventListener('click', () => {
971
+ const currentDir = sortDirBtn.dataset.sortDirection;
972
+ const newDir = currentDir === 'asc' ? 'desc' : 'asc';
973
+ sortDirBtn.dataset.sortDirection = newDir;
974
+ sortDirBtn.setAttribute(
975
+ 'aria-label',
976
+ `Sort ${newDir === 'asc' ? 'ascending' : 'descending'}`
977
+ );
978
+
979
+ // Toggle visibility of arrow SVGs
980
+ const ascArrow = sortDirBtn.querySelector('.sort-asc') as HTMLElement;
981
+ const descArrow = sortDirBtn.querySelector('.sort-desc') as HTMLElement;
982
+
983
+ if (ascArrow && descArrow) {
984
+ ascArrow.style.display = newDir === 'asc' ? '' : 'none';
985
+ descArrow.style.display = newDir === 'desc' ? '' : 'none';
986
+ }
987
+
988
+ emitFilterChange();
989
+ });
990
+
991
+ // Clear all filters
992
+ clearAllFiltersBtn?.addEventListener('click', () => {
993
+ searchInput.value = '';
994
+ if (clearSearchBtn) clearSearchBtn.style.display = 'none';
995
+ typeCheckboxes.forEach((cb) => (cb.checked = false));
996
+ updateMultiSelectValue();
997
+ projectSelect.value = '';
998
+ statusButtons.forEach((b) => b.classList.remove('active'));
999
+ statusButtons[0]?.classList.add('active');
1000
+ sortSelect.value = 'cost';
1001
+ sortDirBtn.dataset.sortDirection = 'desc';
1002
+
1003
+ // Reset arrow visibility
1004
+ const ascArrow = sortDirBtn.querySelector('.sort-asc') as HTMLElement;
1005
+ const descArrow = sortDirBtn.querySelector('.sort-desc') as HTMLElement;
1006
+ if (ascArrow && descArrow) {
1007
+ ascArrow.style.display = 'none';
1008
+ descArrow.style.display = '';
1009
+ }
1010
+
1011
+ emitFilterChange();
1012
+ });
1013
+
1014
+ // Listen for external filter type events (from StatsHero pills)
1015
+ window.addEventListener('usage:filter-type', ((e: CustomEvent) => {
1016
+ const type = e.detail?.type;
1017
+ if (type) {
1018
+ // Clear all checkboxes first
1019
+ typeCheckboxes.forEach((cb) => (cb.checked = false));
1020
+ // Check only the selected type
1021
+ const targetCheckbox = Array.from(typeCheckboxes).find((cb) => cb.value === type);
1022
+ if (targetCheckbox) {
1023
+ targetCheckbox.checked = true;
1024
+ }
1025
+ updateMultiSelectValue();
1026
+ emitFilterChange();
1027
+ }
1028
+ }) as EventListener);
1029
+
1030
+ // Initial state
1031
+ updateActiveFilters(getFilters());
1032
+ }
1033
+ </script>