aws-inventory-manager 0.17.12__py3-none-any.whl

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 (152) hide show
  1. aws_inventory_manager-0.17.12.dist-info/LICENSE +21 -0
  2. aws_inventory_manager-0.17.12.dist-info/METADATA +1292 -0
  3. aws_inventory_manager-0.17.12.dist-info/RECORD +152 -0
  4. aws_inventory_manager-0.17.12.dist-info/WHEEL +5 -0
  5. aws_inventory_manager-0.17.12.dist-info/entry_points.txt +2 -0
  6. aws_inventory_manager-0.17.12.dist-info/top_level.txt +1 -0
  7. src/__init__.py +3 -0
  8. src/aws/__init__.py +11 -0
  9. src/aws/client.py +128 -0
  10. src/aws/credentials.py +191 -0
  11. src/aws/rate_limiter.py +177 -0
  12. src/cli/__init__.py +12 -0
  13. src/cli/config.py +130 -0
  14. src/cli/main.py +4046 -0
  15. src/cloudtrail/__init__.py +5 -0
  16. src/cloudtrail/query.py +642 -0
  17. src/config_service/__init__.py +21 -0
  18. src/config_service/collector.py +346 -0
  19. src/config_service/detector.py +256 -0
  20. src/config_service/resource_type_mapping.py +328 -0
  21. src/cost/__init__.py +5 -0
  22. src/cost/analyzer.py +226 -0
  23. src/cost/explorer.py +209 -0
  24. src/cost/reporter.py +237 -0
  25. src/delta/__init__.py +5 -0
  26. src/delta/calculator.py +206 -0
  27. src/delta/differ.py +185 -0
  28. src/delta/formatters.py +272 -0
  29. src/delta/models.py +154 -0
  30. src/delta/reporter.py +234 -0
  31. src/matching/__init__.py +6 -0
  32. src/matching/config.py +52 -0
  33. src/matching/normalizer.py +450 -0
  34. src/matching/prompts.py +33 -0
  35. src/models/__init__.py +21 -0
  36. src/models/config_diff.py +135 -0
  37. src/models/cost_report.py +87 -0
  38. src/models/deletion_operation.py +104 -0
  39. src/models/deletion_record.py +97 -0
  40. src/models/delta_report.py +122 -0
  41. src/models/efs_resource.py +80 -0
  42. src/models/elasticache_resource.py +90 -0
  43. src/models/group.py +318 -0
  44. src/models/inventory.py +133 -0
  45. src/models/protection_rule.py +123 -0
  46. src/models/report.py +288 -0
  47. src/models/resource.py +111 -0
  48. src/models/security_finding.py +102 -0
  49. src/models/snapshot.py +122 -0
  50. src/restore/__init__.py +20 -0
  51. src/restore/audit.py +175 -0
  52. src/restore/cleaner.py +461 -0
  53. src/restore/config.py +209 -0
  54. src/restore/deleter.py +976 -0
  55. src/restore/dependency.py +254 -0
  56. src/restore/safety.py +115 -0
  57. src/security/__init__.py +0 -0
  58. src/security/checks/__init__.py +0 -0
  59. src/security/checks/base.py +56 -0
  60. src/security/checks/ec2_checks.py +88 -0
  61. src/security/checks/elasticache_checks.py +149 -0
  62. src/security/checks/iam_checks.py +102 -0
  63. src/security/checks/rds_checks.py +140 -0
  64. src/security/checks/s3_checks.py +95 -0
  65. src/security/checks/secrets_checks.py +96 -0
  66. src/security/checks/sg_checks.py +142 -0
  67. src/security/cis_mapper.py +97 -0
  68. src/security/models.py +53 -0
  69. src/security/reporter.py +174 -0
  70. src/security/scanner.py +87 -0
  71. src/snapshot/__init__.py +6 -0
  72. src/snapshot/capturer.py +453 -0
  73. src/snapshot/filter.py +259 -0
  74. src/snapshot/inventory_storage.py +236 -0
  75. src/snapshot/report_formatter.py +250 -0
  76. src/snapshot/reporter.py +189 -0
  77. src/snapshot/resource_collectors/__init__.py +5 -0
  78. src/snapshot/resource_collectors/apigateway.py +140 -0
  79. src/snapshot/resource_collectors/backup.py +136 -0
  80. src/snapshot/resource_collectors/base.py +81 -0
  81. src/snapshot/resource_collectors/cloudformation.py +55 -0
  82. src/snapshot/resource_collectors/cloudwatch.py +109 -0
  83. src/snapshot/resource_collectors/codebuild.py +69 -0
  84. src/snapshot/resource_collectors/codepipeline.py +82 -0
  85. src/snapshot/resource_collectors/dynamodb.py +65 -0
  86. src/snapshot/resource_collectors/ec2.py +240 -0
  87. src/snapshot/resource_collectors/ecs.py +215 -0
  88. src/snapshot/resource_collectors/efs_collector.py +102 -0
  89. src/snapshot/resource_collectors/eks.py +200 -0
  90. src/snapshot/resource_collectors/elasticache_collector.py +79 -0
  91. src/snapshot/resource_collectors/elb.py +126 -0
  92. src/snapshot/resource_collectors/eventbridge.py +156 -0
  93. src/snapshot/resource_collectors/glue.py +199 -0
  94. src/snapshot/resource_collectors/iam.py +188 -0
  95. src/snapshot/resource_collectors/kms.py +111 -0
  96. src/snapshot/resource_collectors/lambda_func.py +139 -0
  97. src/snapshot/resource_collectors/rds.py +109 -0
  98. src/snapshot/resource_collectors/route53.py +86 -0
  99. src/snapshot/resource_collectors/s3.py +105 -0
  100. src/snapshot/resource_collectors/secretsmanager.py +70 -0
  101. src/snapshot/resource_collectors/sns.py +68 -0
  102. src/snapshot/resource_collectors/sqs.py +82 -0
  103. src/snapshot/resource_collectors/ssm.py +160 -0
  104. src/snapshot/resource_collectors/stepfunctions.py +74 -0
  105. src/snapshot/resource_collectors/vpcendpoints.py +79 -0
  106. src/snapshot/resource_collectors/waf.py +159 -0
  107. src/snapshot/storage.py +351 -0
  108. src/storage/__init__.py +21 -0
  109. src/storage/audit_store.py +419 -0
  110. src/storage/database.py +294 -0
  111. src/storage/group_store.py +763 -0
  112. src/storage/inventory_store.py +320 -0
  113. src/storage/resource_store.py +416 -0
  114. src/storage/schema.py +339 -0
  115. src/storage/snapshot_store.py +363 -0
  116. src/utils/__init__.py +12 -0
  117. src/utils/export.py +305 -0
  118. src/utils/hash.py +60 -0
  119. src/utils/logging.py +63 -0
  120. src/utils/pagination.py +41 -0
  121. src/utils/paths.py +51 -0
  122. src/utils/progress.py +41 -0
  123. src/utils/unsupported_resources.py +306 -0
  124. src/web/__init__.py +5 -0
  125. src/web/app.py +97 -0
  126. src/web/dependencies.py +69 -0
  127. src/web/routes/__init__.py +1 -0
  128. src/web/routes/api/__init__.py +18 -0
  129. src/web/routes/api/charts.py +156 -0
  130. src/web/routes/api/cleanup.py +186 -0
  131. src/web/routes/api/filters.py +253 -0
  132. src/web/routes/api/groups.py +305 -0
  133. src/web/routes/api/inventories.py +80 -0
  134. src/web/routes/api/queries.py +202 -0
  135. src/web/routes/api/resources.py +393 -0
  136. src/web/routes/api/snapshots.py +314 -0
  137. src/web/routes/api/views.py +260 -0
  138. src/web/routes/pages.py +198 -0
  139. src/web/services/__init__.py +1 -0
  140. src/web/templates/base.html +955 -0
  141. src/web/templates/components/navbar.html +31 -0
  142. src/web/templates/components/sidebar.html +104 -0
  143. src/web/templates/pages/audit_logs.html +86 -0
  144. src/web/templates/pages/cleanup.html +279 -0
  145. src/web/templates/pages/dashboard.html +227 -0
  146. src/web/templates/pages/diff.html +175 -0
  147. src/web/templates/pages/error.html +30 -0
  148. src/web/templates/pages/groups.html +721 -0
  149. src/web/templates/pages/queries.html +246 -0
  150. src/web/templates/pages/resources.html +2429 -0
  151. src/web/templates/pages/snapshot_detail.html +271 -0
  152. src/web/templates/pages/snapshots.html +429 -0
@@ -0,0 +1,2429 @@
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Resources - AWS Inventory Browser{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="flex flex-col h-full max-w-full overflow-hidden gap-2" x-data="{
7
+ // Collapsible filters - collapsed by default to maximize table space
8
+ filtersCollapsed: true,
9
+
10
+ // Simple filter mode fields
11
+ selectedTypes: [],
12
+ selectedRegions: [],
13
+ snapshot: '',
14
+ search: '',
15
+ showTypeDropdown: false,
16
+ showRegionDropdown: false,
17
+
18
+ // Group filter
19
+ availableGroups: [],
20
+ selectedGroupFilter: '',
21
+ groupFilterMode: 'none', // 'none', 'in_group', 'not_in_group'
22
+ showGroupFilterDropdown: false,
23
+
24
+ // Add to Group modal
25
+ showAddToGroupModal: false,
26
+ selectedResources: [],
27
+ targetGroupName: '',
28
+ addToGroupLoading: false,
29
+
30
+ // Group members cache for advanced filter
31
+ groupMembersCache: {}, // { groupName: Set of 'name|type' keys }
32
+
33
+ toggleType(t) {
34
+ const idx = this.selectedTypes.indexOf(t);
35
+ if (idx === -1) this.selectedTypes.push(t);
36
+ else this.selectedTypes.splice(idx, 1);
37
+ },
38
+
39
+ toggleRegion(r) {
40
+ const idx = this.selectedRegions.indexOf(r);
41
+ if (idx === -1) this.selectedRegions.push(r);
42
+ else this.selectedRegions.splice(idx, 1);
43
+ },
44
+
45
+ // Advanced filter mode with nested groups
46
+ advancedMode: false,
47
+ // Root filter group - contains conditions and nested groups
48
+ filterRoot: {
49
+ logic: 'AND',
50
+ items: [] // Each item is either {type:'condition',...} or {type:'group', logic:'AND/OR', items:[...]}
51
+ },
52
+ nextGroupId: 1, // For generating unique IDs
53
+ baseFilterFields: [
54
+ { value: 'name', label: 'Name', type: 'text' },
55
+ { value: 'arn', label: 'ARN', type: 'text' },
56
+ { value: 'resource_type', label: 'Type', type: 'select' },
57
+ { value: 'region', label: 'Region', type: 'select' },
58
+ { value: 'snapshot_name', label: 'Snapshot', type: 'select' },
59
+ { value: 'config_hash', label: 'Config Hash', type: 'text' },
60
+ { value: 'group_membership', label: 'Group Membership', type: 'group' },
61
+ { value: '_created_by', label: 'Created By', type: 'text', isTagField: true },
62
+ { value: '_created_by_type', label: 'Creator Type', type: 'select', isTagField: true },
63
+ { value: '_created_at', label: 'Creation Time', type: 'text', isTagField: true }
64
+ ],
65
+ availableTagKeys: [],
66
+ fieldValues: {}, // Cache of available values per field
67
+ loadingValues: {}, // Track loading state per field
68
+
69
+ get filterFields() {
70
+ // Combine base fields with dynamic tag fields
71
+ const tagFields = this.availableTagKeys.map(key => ({
72
+ value: 'tag:' + key,
73
+ label: 'Tag: ' + key,
74
+ type: 'select',
75
+ isTag: true,
76
+ tagKey: key
77
+ }));
78
+ return [...this.baseFilterFields, ...tagFields];
79
+ },
80
+
81
+ filterOperators: [
82
+ { value: 'equals', label: 'equals', multi: false },
83
+ { value: 'not_equals', label: 'does not equal', multi: false },
84
+ { value: 'in', label: 'is one of', multi: true },
85
+ { value: 'not_in', label: 'is not one of', multi: true },
86
+ { value: 'contains', label: 'contains', multi: false },
87
+ { value: 'contains_any', label: 'contains any of', multi: true },
88
+ { value: 'not_contains', label: 'does not contain', multi: false },
89
+ { value: 'starts_with', label: 'starts with', multi: false },
90
+ { value: 'not_starts_with', label: 'does not start with', multi: false },
91
+ { value: 'ends_with', label: 'ends with', multi: false },
92
+ { value: 'not_ends_with', label: 'does not end with', multi: false },
93
+ { value: 'is_empty', label: 'is empty', multi: false },
94
+ { value: 'is_not_empty', label: 'is not empty', multi: false },
95
+ { value: 'in_group', label: 'is in group', multi: false, groupOnly: true },
96
+ { value: 'not_in_group', label: 'is not in group', multi: false, groupOnly: true }
97
+ ],
98
+
99
+ // Get operators available for a field type
100
+ getOperatorsForField(fieldValue) {
101
+ const field = this.filterFields.find(f => f.value === fieldValue);
102
+ if (field && field.type === 'group') {
103
+ return this.filterOperators.filter(op => op.groupOnly);
104
+ }
105
+ return this.filterOperators.filter(op => !op.groupOnly);
106
+ },
107
+
108
+ isMultiOperator(operator) {
109
+ const op = this.filterOperators.find(o => o.value === operator);
110
+ return op ? op.multi : false;
111
+ },
112
+
113
+ // UI state
114
+ savedFilters: [],
115
+ savedViews: [],
116
+ showSaveFilterModal: false,
117
+ showSaveViewModal: false,
118
+ showColumnModal: false,
119
+ filterName: '',
120
+ filterDescription: '',
121
+ viewName: '',
122
+ viewDescription: '',
123
+ sortBy: 'name',
124
+ sortOrder: 'asc',
125
+ baseColumns: [
126
+ { field: 'name', label: 'Name', visible: true, isBase: true, width: 150, frozen: true },
127
+ { field: 'arn', label: 'ARN', visible: true, isBase: true, width: 200, frozen: false },
128
+ { field: 'resource_type', label: 'Type', visible: true, isBase: true, width: 140, frozen: false },
129
+ { field: 'region', label: 'Region', visible: true, isBase: true, width: 100, frozen: false },
130
+ { field: 'snapshot_name', label: 'Snapshot', visible: true, isBase: true, width: 120, frozen: false },
131
+ { field: 'canonical_name', label: 'Canonical Name', visible: false, isBase: true, width: 150, frozen: false },
132
+ { field: 'normalized_name', label: 'Normalized Name', visible: false, isBase: true, width: 150, frozen: false },
133
+ { field: 'normalization_method', label: 'Norm Method', visible: false, isBase: true, width: 100, frozen: false },
134
+ { field: '_created_by', label: 'Created By', visible: false, isBase: true, width: 180, frozen: false, isTagField: true },
135
+ { field: '_created_by_type', label: 'Creator Type', visible: false, isBase: true, width: 90, frozen: false, isTagField: true },
136
+ { field: '_created_at', label: 'Creation Time', visible: false, isBase: true, width: 130, frozen: false, isTagField: true },
137
+ { field: 'tags', label: 'All Tags', visible: false, isBase: true, width: 200, frozen: false },
138
+ { field: 'created_at', label: 'Created', visible: false, isBase: true, width: 130, frozen: false },
139
+ { field: 'config_hash', label: 'Config Hash', visible: false, isBase: true, width: 120, frozen: false }
140
+ ],
141
+ tagColumns: [], // Dynamic tag columns
142
+
143
+ // Column resize state
144
+ resizingColumn: null,
145
+ resizeStartX: 0,
146
+ resizeStartWidth: 0,
147
+
148
+ getColumnWidth(field) {
149
+ const col = this.columns.find(c => c.field === field);
150
+ return col ? col.width : 150;
151
+ },
152
+
153
+ setColumnWidth(field, width) {
154
+ const minWidth = 80;
155
+ const maxWidth = 600;
156
+ const newWidth = Math.max(minWidth, Math.min(maxWidth, width));
157
+
158
+ // Check in base columns
159
+ let col = this.baseColumns.find(c => c.field === field);
160
+ if (col) {
161
+ col.width = newWidth;
162
+ return;
163
+ }
164
+ // Check in tag columns
165
+ col = this.tagColumns.find(c => c.field === field);
166
+ if (col) col.width = newWidth;
167
+ },
168
+
169
+ get columns() {
170
+ return [...this.baseColumns, ...this.tagColumns];
171
+ },
172
+
173
+ initTagColumns() {
174
+ // Create tag columns from available tag keys
175
+ this.tagColumns = this.availableTagKeys.map(key => ({
176
+ field: 'tag:' + key,
177
+ label: 'Tag: ' + key,
178
+ visible: false,
179
+ isTag: true,
180
+ tagKey: key,
181
+ width: 150,
182
+ frozen: false
183
+ }));
184
+ },
185
+
186
+ toggleFrozen(field) {
187
+ let col = this.baseColumns.find(c => c.field === field);
188
+ if (col) {
189
+ col.frozen = !col.frozen;
190
+ return;
191
+ }
192
+ col = this.tagColumns.find(c => c.field === field);
193
+ if (col) col.frozen = !col.frozen;
194
+ },
195
+ currentViewId: null,
196
+ resourceData: null,
197
+ allResources: null,
198
+
199
+ get visibleColumns() {
200
+ return this.columns.filter(c => c.visible);
201
+ },
202
+
203
+ toggleColumn(field) {
204
+ // Check in base columns first
205
+ let col = this.baseColumns.find(c => c.field === field);
206
+ if (col) {
207
+ col.visible = !col.visible;
208
+ return;
209
+ }
210
+ // Check in tag columns
211
+ col = this.tagColumns.find(c => c.field === field);
212
+ if (col) col.visible = !col.visible;
213
+ },
214
+
215
+ async loadFieldValues(field) {
216
+ // Don't reload if already cached
217
+ if (this.fieldValues[field]) return;
218
+
219
+ this.loadingValues[field] = true;
220
+
221
+ try {
222
+ if (field === 'resource_type') {
223
+ // Types: global across inventory (all snapshots)
224
+ const resp = await fetch('/api/resources/types');
225
+ const data = await resp.json();
226
+ this.fieldValues[field] = data.types || [];
227
+ } else if (field === 'region') {
228
+ // Regions: global across inventory (all snapshots)
229
+ const resp = await fetch('/api/resources/regions');
230
+ const data = await resp.json();
231
+ this.fieldValues[field] = data.regions || [];
232
+ } else if (field === 'snapshot_name') {
233
+ // Snapshots: always global
234
+ const resp = await fetch('/api/snapshots');
235
+ const data = await resp.json();
236
+ this.fieldValues[field] = (data.snapshots || []).map(s => s.name);
237
+ } else if (field.startsWith('tag:')) {
238
+ // Tag values: global across inventory (all snapshots)
239
+ const tagKey = field.substring(4);
240
+ const resp = await fetch(`/api/resources/tags/values?key=${encodeURIComponent(tagKey)}`);
241
+ const data = await resp.json();
242
+ this.fieldValues[field] = data.values || [];
243
+ }
244
+ } catch (e) {
245
+ console.error('Failed to load values for', field, e);
246
+ this.fieldValues[field] = [];
247
+ }
248
+ this.loadingValues[field] = false;
249
+ },
250
+
251
+ getFieldType(field) {
252
+ const f = this.filterFields.find(ff => ff.value === field);
253
+ return f ? f.type : 'text';
254
+ },
255
+
256
+ setSort(field) {
257
+ if (this.sortBy === field) {
258
+ this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
259
+ } else {
260
+ this.sortBy = field;
261
+ this.sortOrder = 'asc';
262
+ }
263
+ this.applySort();
264
+ },
265
+
266
+ applySort() {
267
+ if (this.resourceData && this.resourceData.resources) {
268
+ this.resourceData.resources.sort((a, b) => {
269
+ let aVal = a[this.sortBy] || '';
270
+ let bVal = b[this.sortBy] || '';
271
+ if (typeof aVal === 'string') aVal = aVal.toLowerCase();
272
+ if (typeof bVal === 'string') bVal = bVal.toLowerCase();
273
+ if (aVal < bVal) return this.sortOrder === 'asc' ? -1 : 1;
274
+ if (aVal > bVal) return this.sortOrder === 'asc' ? 1 : -1;
275
+ return 0;
276
+ });
277
+ }
278
+ },
279
+
280
+ // Add a condition to a group (or root if no groupId)
281
+ addCondition(group = null) {
282
+ const targetGroup = group || this.filterRoot;
283
+ targetGroup.items.push({
284
+ type: 'condition',
285
+ field: 'name',
286
+ operator: 'contains',
287
+ values: [] // Array for multi-select support
288
+ });
289
+ },
290
+
291
+ // Add a nested group
292
+ addGroup(parentGroup = null) {
293
+ const targetGroup = parentGroup || this.filterRoot;
294
+ targetGroup.items.push({
295
+ type: 'group',
296
+ id: this.nextGroupId++,
297
+ logic: targetGroup.logic === 'AND' ? 'OR' : 'AND', // Alternate logic
298
+ items: []
299
+ });
300
+ },
301
+
302
+ // Remove an item (condition or group) from a group
303
+ removeItem(group, index) {
304
+ group.items.splice(index, 1);
305
+ },
306
+
307
+ // Toggle value in multi-select
308
+ toggleValue(condition, value) {
309
+ const idx = condition.values.indexOf(value);
310
+ if (idx === -1) {
311
+ condition.values.push(value);
312
+ } else {
313
+ condition.values.splice(idx, 1);
314
+ }
315
+ },
316
+
317
+ needsValue(operator) {
318
+ return !['is_empty', 'is_not_empty'].includes(operator);
319
+ },
320
+
321
+ // Clear all filters
322
+ clearFilters() {
323
+ this.filterRoot = { logic: 'AND', items: [] };
324
+ },
325
+
326
+ // Load group members into cache for filtering
327
+ async loadGroupMembersForFilter(groupName) {
328
+ if (this.groupMembersCache[groupName]) return;
329
+
330
+ try {
331
+ const response = await fetch('/api/groups/' + encodeURIComponent(groupName) + '/members?limit=1000');
332
+ if (response.ok) {
333
+ const result = await response.json();
334
+ // Create a Set of 'name|type' keys for fast lookup
335
+ const memberSet = new Set();
336
+ for (const member of result.members) {
337
+ memberSet.add(member.resource_name + '|' + member.resource_type);
338
+ }
339
+ this.groupMembersCache[groupName] = memberSet;
340
+ }
341
+ } catch (error) {
342
+ console.error('Failed to load group members:', error);
343
+ this.groupMembersCache[groupName] = new Set();
344
+ }
345
+ },
346
+
347
+ // Check if resource is in a group (by name + type)
348
+ isResourceInGroup(resource, groupName) {
349
+ const memberSet = this.groupMembersCache[groupName];
350
+ if (!memberSet) return false;
351
+
352
+ const resourceName = resource.name || this.extractResourceName(resource.arn, resource.resource_type);
353
+ const key = resourceName + '|' + resource.resource_type;
354
+ return memberSet.has(key);
355
+ },
356
+
357
+ // Simple resource name extraction from ARN
358
+ extractResourceName(arn, resourceType) {
359
+ if (!arn) return '';
360
+ const parts = arn.split(':');
361
+ const lastPart = parts[parts.length - 1] || '';
362
+ if (lastPart.includes('/')) {
363
+ return lastPart.split('/').pop() || lastPart;
364
+ }
365
+ return lastPart;
366
+ },
367
+
368
+ // Evaluate a single condition against a resource
369
+ evaluateCondition(resource, condition) {
370
+ // Handle group_membership field specially
371
+ if (condition.field === 'group_membership') {
372
+ const groupName = condition.values[0];
373
+ if (!groupName) return true;
374
+
375
+ const isInGroup = this.isResourceInGroup(resource, groupName);
376
+ if (condition.operator === 'in_group') {
377
+ return isInGroup;
378
+ } else if (condition.operator === 'not_in_group') {
379
+ return !isInGroup;
380
+ }
381
+ return true;
382
+ }
383
+
384
+ // Get search values (array for multi-select)
385
+ const searchValues = (condition.values || []).map(v => v.toLowerCase());
386
+ const singleValue = searchValues[0] || '';
387
+
388
+ // Get field value from resource
389
+ let fieldValue = '';
390
+ // Check if this is a tag field (either tag:KEY or a creator field like _created_by)
391
+ const fieldDef = this.baseFilterFields.find(f => f.value === condition.field);
392
+ if (condition.field.startsWith('tag:')) {
393
+ const tagKey = condition.field.substring(4);
394
+ const tags = resource.tags || {};
395
+ fieldValue = (tags[tagKey] || '').toString().toLowerCase();
396
+ } else if (fieldDef && fieldDef.isTagField) {
397
+ // Creator fields are stored in tags
398
+ const tags = resource.tags || {};
399
+ fieldValue = (tags[condition.field] || '').toString().toLowerCase();
400
+ } else if (condition.field === 'tag_key') {
401
+ // Legacy: any tag key matches
402
+ const tags = resource.tags || {};
403
+ const tagKeys = Object.keys(tags).map(k => k.toLowerCase());
404
+ return this.evaluateMultiField(tagKeys, condition.operator, searchValues);
405
+ } else if (condition.field === 'tag_value') {
406
+ // Legacy: any tag value matches
407
+ const tags = resource.tags || {};
408
+ const tagValues = Object.values(tags).map(v => (v || '').toString().toLowerCase());
409
+ return this.evaluateMultiField(tagValues, condition.operator, searchValues);
410
+ } else {
411
+ fieldValue = (resource[condition.field] || '').toString().toLowerCase();
412
+ }
413
+
414
+ return this.evaluateOperator(fieldValue, condition.operator, searchValues, singleValue);
415
+ },
416
+
417
+ // Evaluate operator with support for multi-value conditions
418
+ evaluateOperator(fieldValue, operator, searchValues, singleValue) {
419
+ switch (operator) {
420
+ case 'equals':
421
+ return fieldValue === singleValue;
422
+ case 'not_equals':
423
+ return fieldValue !== singleValue;
424
+ case 'in':
425
+ return searchValues.includes(fieldValue);
426
+ case 'not_in':
427
+ return !searchValues.includes(fieldValue);
428
+ case 'contains':
429
+ return fieldValue.includes(singleValue);
430
+ case 'contains_any':
431
+ return searchValues.some(sv => fieldValue.includes(sv));
432
+ case 'not_contains':
433
+ return !fieldValue.includes(singleValue);
434
+ case 'starts_with':
435
+ return fieldValue.startsWith(singleValue);
436
+ case 'not_starts_with':
437
+ return !fieldValue.startsWith(singleValue);
438
+ case 'ends_with':
439
+ return fieldValue.endsWith(singleValue);
440
+ case 'not_ends_with':
441
+ return !fieldValue.endsWith(singleValue);
442
+ case 'is_empty':
443
+ return !fieldValue || fieldValue.trim() === '';
444
+ case 'is_not_empty':
445
+ return fieldValue && fieldValue.trim() !== '';
446
+ default:
447
+ return true;
448
+ }
449
+ },
450
+
451
+ // Evaluate when field has multiple values (like tag keys/values)
452
+ evaluateMultiField(fieldValues, operator, searchValues) {
453
+ const singleValue = searchValues[0] || '';
454
+ switch (operator) {
455
+ case 'equals':
456
+ return fieldValues.some(v => v === singleValue);
457
+ case 'not_equals':
458
+ return !fieldValues.some(v => v === singleValue);
459
+ case 'in':
460
+ return fieldValues.some(v => searchValues.includes(v));
461
+ case 'not_in':
462
+ return !fieldValues.some(v => searchValues.includes(v));
463
+ case 'contains':
464
+ return fieldValues.some(v => v.includes(singleValue));
465
+ case 'contains_any':
466
+ return fieldValues.some(fv => searchValues.some(sv => fv.includes(sv)));
467
+ case 'not_contains':
468
+ return !fieldValues.some(v => v.includes(singleValue));
469
+ case 'starts_with':
470
+ return fieldValues.some(v => v.startsWith(singleValue));
471
+ case 'not_starts_with':
472
+ return !fieldValues.some(v => v.startsWith(singleValue));
473
+ case 'ends_with':
474
+ return fieldValues.some(v => v.endsWith(singleValue));
475
+ case 'not_ends_with':
476
+ return !fieldValues.some(v => v.endsWith(singleValue));
477
+ case 'is_empty':
478
+ return fieldValues.length === 0;
479
+ case 'is_not_empty':
480
+ return fieldValues.length > 0;
481
+ default:
482
+ return true;
483
+ }
484
+ },
485
+
486
+ // Recursively evaluate a filter group
487
+ evaluateGroup(resource, group) {
488
+ if (!group.items || group.items.length === 0) return true;
489
+
490
+ const results = group.items.map(item => {
491
+ if (item.type === 'group') {
492
+ return this.evaluateGroup(resource, item);
493
+ } else {
494
+ return this.evaluateCondition(resource, item);
495
+ }
496
+ });
497
+
498
+ if (group.logic === 'AND') {
499
+ return results.every(r => r);
500
+ } else {
501
+ return results.some(r => r);
502
+ }
503
+ },
504
+
505
+ // Collect all group names from filter conditions
506
+ collectGroupNamesFromFilter(group) {
507
+ const groupNames = [];
508
+ for (const item of (group.items || [])) {
509
+ if (item.type === 'group') {
510
+ groupNames.push(...this.collectGroupNamesFromFilter(item));
511
+ } else if (item.field === 'group_membership' && item.values && item.values[0]) {
512
+ groupNames.push(item.values[0]);
513
+ }
514
+ }
515
+ return groupNames;
516
+ },
517
+
518
+ async applyAdvancedFilter() {
519
+ if (!this.allResources) return;
520
+
521
+ if (!this.filterRoot.items || this.filterRoot.items.length === 0) {
522
+ this.resourceData = { ...this.allResources };
523
+ this.applySort();
524
+ return;
525
+ }
526
+
527
+ // Pre-load group members for any group_membership conditions
528
+ const groupNames = this.collectGroupNamesFromFilter(this.filterRoot);
529
+ const uniqueGroups = [...new Set(groupNames)];
530
+ await Promise.all(uniqueGroups.map(name => this.loadGroupMembersForFilter(name)));
531
+
532
+ const filtered = this.allResources.resources.filter(resource => {
533
+ return this.evaluateGroup(resource, this.filterRoot);
534
+ });
535
+
536
+ this.resourceData = {
537
+ ...this.allResources,
538
+ resources: filtered,
539
+ count: filtered.length
540
+ };
541
+ this.applySort();
542
+ },
543
+
544
+ // Apply simple mode filters client-side (for multi-select)
545
+ applySimpleFilter() {
546
+ if (!this.allResources) return;
547
+
548
+ let filtered = this.allResources.resources;
549
+
550
+ // Filter by search term
551
+ if (this.search) {
552
+ const searchLower = this.search.toLowerCase();
553
+ filtered = filtered.filter(r =>
554
+ (r.name && r.name.toLowerCase().includes(searchLower)) ||
555
+ (r.arn && r.arn.toLowerCase().includes(searchLower))
556
+ );
557
+ }
558
+
559
+ // Filter by selected types
560
+ if (this.selectedTypes.length > 0) {
561
+ filtered = filtered.filter(r => this.selectedTypes.includes(r.resource_type));
562
+ }
563
+
564
+ // Filter by selected regions
565
+ if (this.selectedRegions.length > 0) {
566
+ filtered = filtered.filter(r => this.selectedRegions.includes(r.region));
567
+ }
568
+
569
+ this.resourceData = {
570
+ ...this.allResources,
571
+ resources: filtered,
572
+ count: filtered.length
573
+ };
574
+ this.applySort();
575
+ },
576
+
577
+ // Count total conditions in filter tree
578
+ countConditions(group = null) {
579
+ const g = group || this.filterRoot;
580
+ let count = 0;
581
+ for (const item of g.items || []) {
582
+ if (item.type === 'group') {
583
+ count += this.countConditions(item);
584
+ } else {
585
+ count++;
586
+ }
587
+ }
588
+ return count;
589
+ },
590
+
591
+ // Serialize a group for saving
592
+ serializeGroup(group) {
593
+ return {
594
+ logic: group.logic,
595
+ items: group.items.map(item => {
596
+ if (item.type === 'group') {
597
+ return { type: 'group', ...this.serializeGroup(item) };
598
+ } else {
599
+ return {
600
+ type: 'condition',
601
+ field: item.field,
602
+ operator: item.operator,
603
+ values: item.values || []
604
+ };
605
+ }
606
+ })
607
+ };
608
+ },
609
+
610
+ // Deserialize a group from saved config
611
+ deserializeGroup(config) {
612
+ return {
613
+ logic: config.logic || 'AND',
614
+ items: (config.items || []).map(item => {
615
+ if (item.type === 'group') {
616
+ return { type: 'group', id: this.nextGroupId++, ...this.deserializeGroup(item) };
617
+ } else {
618
+ // Handle legacy single value format
619
+ let values = item.values || [];
620
+ if (values.length === 0 && item.value) {
621
+ values = [item.value];
622
+ }
623
+ return {
624
+ type: 'condition',
625
+ field: item.field,
626
+ operator: item.operator,
627
+ values: values
628
+ };
629
+ }
630
+ })
631
+ };
632
+ },
633
+
634
+ getFilterConfig() {
635
+ if (this.advancedMode) {
636
+ return {
637
+ version: 2, // New nested format
638
+ ...this.serializeGroup(this.filterRoot)
639
+ };
640
+ } else {
641
+ return {
642
+ version: 3, // Simple mode with multi-select
643
+ resource_types: this.selectedTypes.length > 0 ? [...this.selectedTypes] : null,
644
+ regions: this.selectedRegions.length > 0 ? [...this.selectedRegions] : null,
645
+ snapshot: this.snapshot || null,
646
+ search: this.search || null
647
+ };
648
+ }
649
+ },
650
+
651
+ loadFilterConfig(config) {
652
+ if (config.version === 2 || config.items) {
653
+ // New nested format (advanced mode)
654
+ this.advancedMode = true;
655
+ this.filterRoot = this.deserializeGroup(config);
656
+ } else if (config.conditions && config.conditions.length > 0) {
657
+ // Legacy flat format - convert to new format
658
+ this.advancedMode = true;
659
+ this.filterRoot = {
660
+ logic: config.logic || 'AND',
661
+ items: config.conditions.map(c => ({
662
+ type: 'condition',
663
+ field: c.field,
664
+ operator: c.operator,
665
+ values: c.value ? [c.value] : []
666
+ }))
667
+ };
668
+ } else if (config.version === 3) {
669
+ // Simple mode with multi-select (v3)
670
+ this.advancedMode = false;
671
+ this.selectedTypes = config.resource_types || [];
672
+ this.selectedRegions = config.regions || [];
673
+ this.snapshot = config.snapshot || '';
674
+ this.search = config.search || '';
675
+ } else {
676
+ // Legacy simple mode filter (single values)
677
+ this.advancedMode = false;
678
+ this.selectedTypes = config.resource_type ? [config.resource_type] : [];
679
+ this.selectedRegions = config.region ? [config.region] : [];
680
+ this.snapshot = config.snapshot || '';
681
+ this.search = config.search || '';
682
+ }
683
+ },
684
+
685
+ // Load available groups
686
+ async loadGroups() {
687
+ try {
688
+ const resp = await fetch('/api/groups');
689
+ const data = await resp.json();
690
+ this.availableGroups = data.groups || [];
691
+ } catch (e) {
692
+ console.error('Failed to load groups:', e);
693
+ this.availableGroups = [];
694
+ }
695
+ },
696
+
697
+ // Set group filter
698
+ setGroupFilter(mode, groupName) {
699
+ this.groupFilterMode = mode;
700
+ this.selectedGroupFilter = groupName;
701
+ this.showGroupFilterDropdown = false;
702
+ },
703
+
704
+ // Clear group filter
705
+ clearGroupFilter() {
706
+ this.groupFilterMode = 'none';
707
+ this.selectedGroupFilter = '';
708
+ },
709
+
710
+ // Update selected resources from Tabulator
711
+ updateSelectedResources(resources) {
712
+ this.selectedResources = resources || [];
713
+ },
714
+
715
+ // Add selected resources to group
716
+ async addSelectedToGroup() {
717
+ if (!this.targetGroupName || this.selectedResources.length === 0) return;
718
+
719
+ this.addToGroupLoading = true;
720
+ try {
721
+ // Build list of resources with optional logical_id for stable CloudFormation matching
722
+ const arns = this.selectedResources.map(r => {
723
+ const item = {
724
+ arn: r.arn,
725
+ resource_type: r.resource_type
726
+ };
727
+ // Include CloudFormation logical ID if available for stable matching
728
+ // across resource recreations (when ARN suffix changes)
729
+ if (r.tags && r.tags['aws:cloudformation:logical-id']) {
730
+ item.logical_id = r.tags['aws:cloudformation:logical-id'];
731
+ }
732
+ return item;
733
+ });
734
+ const resp = await fetch(`/api/groups/${encodeURIComponent(this.targetGroupName)}/members`, {
735
+ method: 'POST',
736
+ headers: { 'Content-Type': 'application/json' },
737
+ body: JSON.stringify({ arns: arns })
738
+ });
739
+ const result = await resp.json();
740
+
741
+ if (result.added > 0) {
742
+ // Show success notification
743
+ const groupName = this.targetGroupName;
744
+ this.showAddToGroupModal = false;
745
+ this.targetGroupName = '';
746
+ // Clear selection in Tabulator
747
+ if (window.resourceTable) {
748
+ window.resourceTable.deselectRow();
749
+ }
750
+ this.selectedResources = [];
751
+ // Reload groups to update counts
752
+ this.loadGroups();
753
+ alert('Added ' + result.added + ' resource(s) to group');
754
+ } else if (result.skipped > 0) {
755
+ alert('All ' + result.skipped + ' resource(s) already exist in the group.');
756
+ }
757
+ } catch (e) {
758
+ console.error('Failed to add resources to group:', e);
759
+ alert('Failed to add resources to group. Please try again.');
760
+ }
761
+ this.addToGroupLoading = false;
762
+ },
763
+
764
+ // Open add to group modal
765
+ openAddToGroupModal() {
766
+ this.targetGroupName = '';
767
+ this.showAddToGroupModal = true;
768
+ }
769
+ }">
770
+ <!-- Header - Compact -->
771
+ <div class="flex items-center justify-between flex-shrink-0">
772
+ <h1 class="text-base font-bold text-gray-900">Resource Explorer</h1>
773
+ <div class="flex space-x-1">
774
+ <button @click="showColumnModal = true" class="btn btn-secondary">
775
+ <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
776
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"/>
777
+ </svg>
778
+ Columns
779
+ </button>
780
+ <button @click="exportCSV()" class="btn btn-secondary">
781
+ <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
782
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
783
+ </svg>
784
+ CSV
785
+ </button>
786
+ <button @click="exportYAML()" class="btn btn-secondary">
787
+ <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
788
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
789
+ </svg>
790
+ YAML
791
+ </button>
792
+ </div>
793
+ </div>
794
+
795
+ <!-- Saved Views & Filters Bar - Compact single row -->
796
+ <div class="bg-white shadow rounded-lg px-3 py-2 flex items-center gap-4 flex-shrink-0 text-sm">
797
+ <div class="flex items-center gap-2">
798
+ <span class="text-gray-500 font-medium">Views:</span>
799
+ <div id="saved-views-list" hx-get="/api/views" hx-trigger="load" hx-swap="innerHTML" class="flex flex-wrap gap-1">
800
+ <span class="text-gray-400">...</span>
801
+ </div>
802
+ <button @click="showSaveViewModal = true; viewName = ''; viewDescription = '';"
803
+ class="text-blue-600 hover:text-blue-500 text-xs">+ Save</button>
804
+ </div>
805
+ <div class="border-l border-gray-200 h-5"></div>
806
+ <div class="flex items-center gap-2">
807
+ <span class="text-gray-500 font-medium">Filters:</span>
808
+ <div id="saved-filters-list" hx-get="/api/filters" hx-trigger="load" hx-swap="innerHTML" class="flex flex-wrap gap-1">
809
+ <span class="text-gray-400">...</span>
810
+ </div>
811
+ <button @click="showSaveFilterModal = true; filterName = ''; filterDescription = '';"
812
+ class="text-blue-600 hover:text-blue-500 text-xs">+ Save</button>
813
+ </div>
814
+ </div>
815
+
816
+ <!-- Filters -->
817
+ <div class="bg-white shadow rounded-lg flex-shrink-0">
818
+ <!-- Collapsible Header - Compact -->
819
+ <div class="px-3 py-2 flex items-center justify-between cursor-pointer"
820
+ :class="filtersCollapsed ? '' : 'border-b border-gray-200'"
821
+ @click="filtersCollapsed = !filtersCollapsed">
822
+ <div class="flex items-center gap-2">
823
+ <svg class="h-4 w-4 text-gray-500 transition-transform duration-200"
824
+ :class="filtersCollapsed ? '' : 'rotate-90'"
825
+ fill="none" viewBox="0 0 24 24" stroke="currentColor">
826
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
827
+ </svg>
828
+ <span class="text-sm font-medium text-gray-900">Filters</span>
829
+ <span x-show="advancedMode && countConditions() > 0" class="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded-full">
830
+ <span x-text="countConditions()"></span> conditions
831
+ </span>
832
+ <span x-show="!advancedMode && (selectedTypes.length > 0 || selectedRegions.length > 0 || snapshot || search)"
833
+ class="text-xs text-blue-600 bg-blue-50 px-1.5 py-0.5 rounded-full">
834
+ Active
835
+ </span>
836
+ </div>
837
+ <span class="text-xs text-gray-400" x-text="filtersCollapsed ? 'expand' : 'collapse'"></span>
838
+ </div>
839
+
840
+ <!-- Collapsible Content -->
841
+ <div x-show="!filtersCollapsed" x-collapse class="p-4">
842
+ <!-- Filter Mode Toggle -->
843
+ <div class="flex items-center justify-between mb-4">
844
+ <div class="flex items-center space-x-4">
845
+ <span class="text-sm font-medium text-gray-700">Filter Mode:</span>
846
+ <div class="flex rounded-md shadow-sm">
847
+ <button @click.stop="advancedMode = false"
848
+ :class="!advancedMode ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'"
849
+ class="px-4 py-2 text-sm font-medium rounded-l-md border border-gray-300">
850
+ Simple
851
+ </button>
852
+ <button @click.stop="advancedMode = true; if (filterRoot.items.length === 0) addCondition();"
853
+ :class="advancedMode ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'"
854
+ class="px-4 py-2 text-sm font-medium rounded-r-md border border-l-0 border-gray-300">
855
+ Advanced
856
+ </button>
857
+ </div>
858
+ </div>
859
+ </div>
860
+
861
+ <!-- Simple Mode Filters -->
862
+ <div x-show="!advancedMode" x-transition>
863
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-6">
864
+ <!-- Search -->
865
+ <div class="lg:col-span-1">
866
+ <label for="search" class="block text-sm font-medium text-gray-700">Search</label>
867
+ <input type="text" name="search" id="search" x-model="search"
868
+ placeholder="Search by name or ARN..."
869
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
870
+ </div>
871
+
872
+ <!-- Type Filter (Multi-select) -->
873
+ <div class="relative" @click.away="showTypeDropdown = false">
874
+ <label class="block text-sm font-medium text-gray-700">Type</label>
875
+ <button type="button" @click="showTypeDropdown = !showTypeDropdown"
876
+ class="mt-1 relative w-full bg-white border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-pointer focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
877
+ <span class="block truncate" x-text="selectedTypes.length === 0 ? 'All Types' : selectedTypes.length + ' selected'"></span>
878
+ <span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
879
+ <svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
880
+ <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
881
+ </svg>
882
+ </span>
883
+ </button>
884
+ <div x-show="showTypeDropdown" x-cloak
885
+ class="absolute z-20 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
886
+ <div class="px-3 py-2 border-b border-gray-200 flex justify-between items-center">
887
+ <span class="text-xs text-gray-500" x-text="selectedTypes.length + ' selected'"></span>
888
+ <button type="button" @click="selectedTypes = []" class="text-xs text-blue-600 hover:text-blue-800">Clear</button>
889
+ </div>
890
+ {% for t in resource_types %}
891
+ <label class="flex items-center px-3 py-2 hover:bg-gray-100 cursor-pointer">
892
+ <input type="checkbox" :checked="selectedTypes.includes('{{ t }}')" @change="toggleType('{{ t }}')"
893
+ class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
894
+ <span class="ml-2 text-sm text-gray-700 truncate">{{ t }}</span>
895
+ </label>
896
+ {% endfor %}
897
+ </div>
898
+ </div>
899
+
900
+ <!-- Region Filter (Multi-select) -->
901
+ <div class="relative" @click.away="showRegionDropdown = false">
902
+ <label class="block text-sm font-medium text-gray-700">Region</label>
903
+ <button type="button" @click="showRegionDropdown = !showRegionDropdown"
904
+ class="mt-1 relative w-full bg-white border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-pointer focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
905
+ <span class="block truncate" x-text="selectedRegions.length === 0 ? 'All Regions' : selectedRegions.length + ' selected'"></span>
906
+ <span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
907
+ <svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
908
+ <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
909
+ </svg>
910
+ </span>
911
+ </button>
912
+ <div x-show="showRegionDropdown" x-cloak
913
+ class="absolute z-20 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
914
+ <div class="px-3 py-2 border-b border-gray-200 flex justify-between items-center">
915
+ <span class="text-xs text-gray-500" x-text="selectedRegions.length + ' selected'"></span>
916
+ <button type="button" @click="selectedRegions = []" class="text-xs text-blue-600 hover:text-blue-800">Clear</button>
917
+ </div>
918
+ {% for r in regions %}
919
+ <label class="flex items-center px-3 py-2 hover:bg-gray-100 cursor-pointer">
920
+ <input type="checkbox" :checked="selectedRegions.includes('{{ r }}')" @change="toggleRegion('{{ r }}')"
921
+ class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
922
+ <span class="ml-2 text-sm text-gray-700">{{ r }}</span>
923
+ </label>
924
+ {% endfor %}
925
+ </div>
926
+ </div>
927
+
928
+ <!-- Snapshot Filter -->
929
+ <div>
930
+ <label for="snapshot" class="block text-sm font-medium text-gray-700">Snapshot</label>
931
+ <select id="snapshot" name="snapshot" x-model="snapshot"
932
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
933
+ <option value="">All Snapshots</option>
934
+ {% for s in snapshots %}
935
+ <option value="{{ s.name }}">{{ s.name }}</option>
936
+ {% endfor %}
937
+ </select>
938
+ </div>
939
+
940
+ <!-- Group Filter -->
941
+ <div class="relative" @click.away="showGroupFilterDropdown = false" x-init="loadGroups()">
942
+ <label class="block text-sm font-medium text-gray-700">Group Filter</label>
943
+ <button type="button" @click="showGroupFilterDropdown = !showGroupFilterDropdown"
944
+ class="mt-1 relative w-full bg-white border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-pointer focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
945
+ :class="groupFilterMode !== 'none' ? 'border-green-500 ring-1 ring-green-500' : ''">
946
+ <span class="block truncate">
947
+ <template x-if="groupFilterMode === 'none'">
948
+ <span class="text-gray-500">No Group Filter</span>
949
+ </template>
950
+ <template x-if="groupFilterMode === 'in_group'">
951
+ <span class="text-green-700">In: <span x-text="selectedGroupFilter"></span></span>
952
+ </template>
953
+ <template x-if="groupFilterMode === 'not_in_group'">
954
+ <span class="text-amber-700">Not In: <span x-text="selectedGroupFilter"></span></span>
955
+ </template>
956
+ </span>
957
+ <span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
958
+ <svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
959
+ <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
960
+ </svg>
961
+ </span>
962
+ </button>
963
+ <div x-show="showGroupFilterDropdown" x-cloak
964
+ class="absolute z-20 mt-1 w-64 bg-white shadow-lg rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm max-h-80">
965
+ <!-- Clear option -->
966
+ <button type="button" @click="clearGroupFilter(); showGroupFilterDropdown = false; searchResources();"
967
+ class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 border-b border-gray-200">
968
+ <span class="font-medium">No Group Filter</span>
969
+ </button>
970
+
971
+ <!-- Groups list -->
972
+ <template x-if="availableGroups.length === 0">
973
+ <div class="px-3 py-4 text-center text-gray-500 text-sm">
974
+ <p>No groups available.</p>
975
+ <a href="/groups" class="text-blue-600 hover:text-blue-800 text-xs">Create a group</a>
976
+ </div>
977
+ </template>
978
+
979
+ <template x-for="group in availableGroups" :key="group.name">
980
+ <div class="border-b border-gray-100 last:border-0">
981
+ <div class="px-3 py-2 bg-gray-50">
982
+ <span class="text-xs font-semibold text-gray-600" x-text="group.name"></span>
983
+ <span class="text-xs text-gray-400 ml-1">(<span x-text="group.resource_count"></span> resources)</span>
984
+ </div>
985
+ <button type="button"
986
+ @click="setGroupFilter('in_group', group.name); $nextTick(() => searchResources());"
987
+ class="w-full text-left px-3 py-2 text-sm hover:bg-green-50 flex items-center"
988
+ :class="groupFilterMode === 'in_group' && selectedGroupFilter === group.name ? 'bg-green-100 text-green-800' : 'text-gray-700'">
989
+ <svg class="w-4 h-4 mr-2 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
990
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
991
+ </svg>
992
+ Show resources IN this group
993
+ </button>
994
+ <button type="button"
995
+ @click="setGroupFilter('not_in_group', group.name); $nextTick(() => searchResources());"
996
+ class="w-full text-left px-3 py-2 text-sm hover:bg-amber-50 flex items-center"
997
+ :class="groupFilterMode === 'not_in_group' && selectedGroupFilter === group.name ? 'bg-amber-100 text-amber-800' : 'text-gray-700'">
998
+ <svg class="w-4 h-4 mr-2 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
999
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
1000
+ </svg>
1001
+ Show resources NOT in this group
1002
+ </button>
1003
+ </div>
1004
+ </template>
1005
+ </div>
1006
+ </div>
1007
+ </div>
1008
+ </div>
1009
+
1010
+ <!-- Advanced Mode Filters with Nested Groups -->
1011
+ <div x-show="advancedMode" x-transition>
1012
+ <!-- Root Group -->
1013
+ <div class="border-2 border-gray-200 rounded-lg p-4 bg-gray-50">
1014
+ <!-- Root Logic Selector -->
1015
+ <div class="flex items-center justify-between mb-4">
1016
+ <div class="flex items-center space-x-3">
1017
+ <span class="text-sm font-medium text-gray-700">Match</span>
1018
+ <select x-model="filterRoot.logic"
1019
+ class="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-medium"
1020
+ :class="filterRoot.logic === 'AND' ? 'text-blue-700 bg-blue-50' : 'text-purple-700 bg-purple-50'">
1021
+ <option value="AND">ALL of the following (AND)</option>
1022
+ <option value="OR">ANY of the following (OR)</option>
1023
+ </select>
1024
+ </div>
1025
+ <div class="flex space-x-2">
1026
+ <button @click="addCondition(filterRoot)"
1027
+ class="inline-flex items-center px-2 py-1 text-xs font-medium rounded bg-white border border-gray-300 text-gray-700 hover:bg-gray-50">
1028
+ <svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1029
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
1030
+ </svg>
1031
+ Condition
1032
+ </button>
1033
+ <button @click="addGroup(filterRoot)"
1034
+ class="inline-flex items-center px-2 py-1 text-xs font-medium rounded bg-white border border-gray-300 text-gray-700 hover:bg-gray-50">
1035
+ <svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1036
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
1037
+ </svg>
1038
+ Group
1039
+ </button>
1040
+ </div>
1041
+ </div>
1042
+
1043
+ <!-- Items (conditions and nested groups) -->
1044
+ <div class="space-y-2">
1045
+ <template x-for="(item, index) in filterRoot.items" :key="item.id || index">
1046
+ <div>
1047
+ <!-- Logic connector between items -->
1048
+ <div x-show="index > 0" class="flex justify-center -my-1 relative z-10">
1049
+ <span class="px-2 py-0.5 text-xs font-bold rounded-full"
1050
+ :class="filterRoot.logic === 'AND' ? 'bg-blue-100 text-blue-700' : 'bg-purple-100 text-purple-700'"
1051
+ x-text="filterRoot.logic"></span>
1052
+ </div>
1053
+
1054
+ <!-- Nested Group -->
1055
+ <template x-if="item.type === 'group'">
1056
+ <div class="border-2 rounded-lg p-3 ml-4"
1057
+ :class="item.logic === 'AND' ? 'border-blue-200 bg-blue-50/50' : 'border-purple-200 bg-purple-50/50'">
1058
+ <div class="flex items-center justify-between mb-3">
1059
+ <div class="flex items-center space-x-2">
1060
+ <select x-model="item.logic"
1061
+ class="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-xs font-medium"
1062
+ :class="item.logic === 'AND' ? 'text-blue-700 bg-blue-50' : 'text-purple-700 bg-purple-50'">
1063
+ <option value="AND">ALL (AND)</option>
1064
+ <option value="OR">ANY (OR)</option>
1065
+ </select>
1066
+ </div>
1067
+ <div class="flex space-x-1">
1068
+ <button @click="addCondition(item)"
1069
+ class="p-1 text-gray-500 hover:text-gray-700 hover:bg-white rounded">
1070
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1071
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
1072
+ </svg>
1073
+ </button>
1074
+ <button @click="addGroup(item)"
1075
+ class="p-1 text-gray-500 hover:text-gray-700 hover:bg-white rounded">
1076
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1077
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
1078
+ </svg>
1079
+ </button>
1080
+ <button @click="removeItem(filterRoot, index)"
1081
+ class="p-1 text-red-500 hover:text-red-700 hover:bg-red-50 rounded">
1082
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1083
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
1084
+ </svg>
1085
+ </button>
1086
+ </div>
1087
+ </div>
1088
+ <!-- Nested items -->
1089
+ <div class="space-y-2">
1090
+ <template x-for="(subItem, subIndex) in item.items" :key="subItem.id || subIndex">
1091
+ <div>
1092
+ <div x-show="subIndex > 0" class="flex justify-center -my-1 relative z-10">
1093
+ <span class="px-1.5 py-0.5 text-xs font-bold rounded-full"
1094
+ :class="item.logic === 'AND' ? 'bg-blue-100 text-blue-700' : 'bg-purple-100 text-purple-700'"
1095
+ x-text="item.logic"></span>
1096
+ </div>
1097
+ <!-- Sub-condition row -->
1098
+ <template x-if="subItem.type === 'condition'">
1099
+ <div class="flex items-center space-x-2 p-2 bg-white rounded-lg border border-gray-200">
1100
+ <select x-model="subItem.field" @change="loadFieldValues(subItem.field)"
1101
+ class="rounded border-gray-300 text-xs">
1102
+ <template x-for="field in filterFields" :key="field.value">
1103
+ <option :value="field.value" x-text="field.label"></option>
1104
+ </template>
1105
+ </select>
1106
+ <select x-model="subItem.operator"
1107
+ @change="if(getFieldType(subItem.field) === 'group') { subItem.values = []; }"
1108
+ class="rounded border-gray-300 text-xs">
1109
+ <template x-for="op in getOperatorsForField(subItem.field)" :key="op.value">
1110
+ <option :value="op.value" x-text="op.label"></option>
1111
+ </template>
1112
+ </select>
1113
+ <!-- Group selector for group_membership field -->
1114
+ <template x-if="getFieldType(subItem.field) === 'group'">
1115
+ <select x-model="subItem.values[0]"
1116
+ @change="if(!subItem.values[0]) subItem.values = []; else subItem.values = [subItem.values[0]]"
1117
+ class="flex-1 rounded border-gray-300 text-xs">
1118
+ <option value="">Select group...</option>
1119
+ <template x-for="g in availableGroups" :key="g.name">
1120
+ <option :value="g.name" x-text="g.name + ' (' + g.resource_count + ' resources)'"></option>
1121
+ </template>
1122
+ </select>
1123
+ </template>
1124
+ <!-- Multi-select for multi operators -->
1125
+ <template x-if="needsValue(subItem.operator) && isMultiOperator(subItem.operator) && getFieldType(subItem.field) === 'select' && fieldValues[subItem.field]?.length > 0">
1126
+ <div class="flex-1 relative" x-data="{ open: false }">
1127
+ <button @click="open = !open" type="button"
1128
+ class="w-full text-left rounded border border-gray-300 px-2 py-1 text-xs bg-white flex items-center justify-between">
1129
+ <span x-text="subItem.values.length ? subItem.values.join(', ') : 'Select values...'"></span>
1130
+ <svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1131
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
1132
+ </svg>
1133
+ </button>
1134
+ <div x-show="open" @click.away="open = false"
1135
+ class="absolute z-20 mt-1 w-full bg-white border border-gray-300 rounded shadow-lg max-h-48 overflow-y-auto">
1136
+ <template x-for="val in fieldValues[subItem.field]" :key="val">
1137
+ <label class="flex items-center px-2 py-1 hover:bg-gray-50 cursor-pointer">
1138
+ <input type="checkbox" :checked="subItem.values.includes(val)"
1139
+ @change="toggleValue(subItem, val)"
1140
+ class="h-3 w-3 text-blue-600 rounded">
1141
+ <span class="ml-2 text-xs" x-text="val"></span>
1142
+ </label>
1143
+ </template>
1144
+ </div>
1145
+ </div>
1146
+ </template>
1147
+ <!-- Single select dropdown -->
1148
+ <template x-if="needsValue(subItem.operator) && !isMultiOperator(subItem.operator) && getFieldType(subItem.field) === 'select' && fieldValues[subItem.field]?.length > 0">
1149
+ <select x-model="subItem.values[0]" @change="if(!subItem.values[0]) subItem.values = []; else subItem.values = [subItem.values[0]]"
1150
+ class="flex-1 rounded border-gray-300 text-xs">
1151
+ <option value="">Select...</option>
1152
+ <template x-for="val in fieldValues[subItem.field]" :key="val">
1153
+ <option :value="val" x-text="val"></option>
1154
+ </template>
1155
+ </select>
1156
+ </template>
1157
+ <!-- Text input (exclude group type) -->
1158
+ <template x-if="needsValue(subItem.operator) && getFieldType(subItem.field) !== 'group' && (getFieldType(subItem.field) !== 'select' || !fieldValues[subItem.field]?.length)">
1159
+ <input type="text" x-model="subItem.values[0]"
1160
+ @input="subItem.values = subItem.values[0] ? [subItem.values[0]] : []"
1161
+ placeholder="Value..." class="flex-1 rounded border-gray-300 text-xs">
1162
+ </template>
1163
+ <button @click="removeItem(item, subIndex)"
1164
+ class="p-1 text-red-500 hover:text-red-700 hover:bg-red-50 rounded">
1165
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1166
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
1167
+ </svg>
1168
+ </button>
1169
+ </div>
1170
+ </template>
1171
+ </div>
1172
+ </template>
1173
+ <div x-show="item.items.length === 0" class="text-xs text-gray-400 text-center py-2">
1174
+ Empty group - add conditions
1175
+ </div>
1176
+ </div>
1177
+ </div>
1178
+ </template>
1179
+
1180
+ <!-- Condition Row -->
1181
+ <template x-if="item.type === 'condition'">
1182
+ <div class="flex items-center space-x-2 p-3 bg-white rounded-lg border border-gray-200">
1183
+ <!-- Field -->
1184
+ <select x-model="item.field"
1185
+ @change="loadFieldValues(item.field)"
1186
+ class="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
1187
+ <template x-for="field in filterFields" :key="field.value">
1188
+ <option :value="field.value" x-text="field.label"></option>
1189
+ </template>
1190
+ </select>
1191
+
1192
+ <!-- Operator -->
1193
+ <select x-model="item.operator"
1194
+ @change="if(getFieldType(item.field) === 'group') { item.values = []; }"
1195
+ class="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
1196
+ <template x-for="op in getOperatorsForField(item.field)" :key="op.value">
1197
+ <option :value="op.value" x-text="op.label"></option>
1198
+ </template>
1199
+ </select>
1200
+
1201
+ <!-- Group selector for group_membership field -->
1202
+ <template x-if="getFieldType(item.field) === 'group'">
1203
+ <select x-model="item.values[0]"
1204
+ @change="if(!item.values[0]) item.values = []; else item.values = [item.values[0]]"
1205
+ class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
1206
+ <option value="">Select group...</option>
1207
+ <template x-for="g in availableGroups" :key="g.name">
1208
+ <option :value="g.name" x-text="g.name + ' (' + g.resource_count + ' resources)'"></option>
1209
+ </template>
1210
+ </select>
1211
+ </template>
1212
+
1213
+ <!-- Multi-select dropdown for multi operators -->
1214
+ <template x-if="needsValue(item.operator) && isMultiOperator(item.operator) && getFieldType(item.field) === 'select' && fieldValues[item.field]?.length > 0">
1215
+ <div class="flex-1 relative" x-data="{ open: false }">
1216
+ <button @click="open = !open" type="button"
1217
+ class="w-full text-left rounded-md border border-gray-300 shadow-sm px-3 py-2 bg-white text-sm flex items-center justify-between">
1218
+ <span class="truncate" x-text="item.values.length ? item.values.slice(0,3).join(', ') + (item.values.length > 3 ? ' +' + (item.values.length - 3) : '') : 'Select values...'"></span>
1219
+ <svg class="h-5 w-5 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1220
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
1221
+ </svg>
1222
+ </button>
1223
+ <div x-show="open" @click.away="open = false"
1224
+ class="absolute z-20 mt-1 w-full bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
1225
+ <template x-for="val in fieldValues[item.field]" :key="val">
1226
+ <label class="flex items-center px-3 py-2 hover:bg-gray-50 cursor-pointer">
1227
+ <input type="checkbox" :checked="item.values.includes(val)"
1228
+ @change="toggleValue(item, val)"
1229
+ class="h-4 w-4 text-blue-600 rounded border-gray-300">
1230
+ <span class="ml-2 text-sm" x-text="val"></span>
1231
+ </label>
1232
+ </template>
1233
+ </div>
1234
+ </div>
1235
+ </template>
1236
+
1237
+ <!-- Single select dropdown -->
1238
+ <template x-if="needsValue(item.operator) && !isMultiOperator(item.operator) && getFieldType(item.field) === 'select' && fieldValues[item.field]?.length > 0">
1239
+ <select x-model="item.values[0]"
1240
+ @change="if(!item.values[0]) item.values = []; else item.values = [item.values[0]]"
1241
+ class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
1242
+ <option value="">Select value...</option>
1243
+ <template x-for="val in fieldValues[item.field]" :key="val">
1244
+ <option :value="val" x-text="val"></option>
1245
+ </template>
1246
+ </select>
1247
+ </template>
1248
+
1249
+ <!-- Text input for other fields (exclude group type) -->
1250
+ <template x-if="needsValue(item.operator) && getFieldType(item.field) !== 'group' && (getFieldType(item.field) !== 'select' || !fieldValues[item.field]?.length)">
1251
+ <input type="text"
1252
+ x-model="item.values[0]"
1253
+ @input="item.values = item.values[0] ? [item.values[0]] : []"
1254
+ placeholder="Enter value..."
1255
+ class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
1256
+ </template>
1257
+
1258
+ <!-- Loading indicator -->
1259
+ <span x-show="loadingValues[item.field]" class="text-gray-400">
1260
+ <svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
1261
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
1262
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
1263
+ </svg>
1264
+ </span>
1265
+
1266
+ <!-- Remove button -->
1267
+ <button @click="removeItem(filterRoot, index)"
1268
+ class="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md">
1269
+ <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1270
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
1271
+ </svg>
1272
+ </button>
1273
+ </div>
1274
+ </template>
1275
+ </div>
1276
+ </template>
1277
+
1278
+ <!-- Empty state -->
1279
+ <div x-show="filterRoot.items.length === 0" class="text-center py-8 text-gray-500">
1280
+ <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1281
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
1282
+ </svg>
1283
+ <p class="mt-2 text-sm">No filter conditions yet</p>
1284
+ <p class="text-xs text-gray-400">Click "Condition" or "Group" above to start building your filter</p>
1285
+ </div>
1286
+ </div>
1287
+ </div>
1288
+ </div>
1289
+
1290
+ <div class="mt-4 flex items-center space-x-3">
1291
+ <button @click.stop="searchResources()" class="btn btn-primary">
1292
+ <svg class="-ml-0.5 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1293
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
1294
+ </svg>
1295
+ Search
1296
+ </button>
1297
+ <button @click.stop="if (advancedMode) { clearFilters(); applyAdvancedFilter(); } else { selectedTypes = []; selectedRegions = []; snapshot = ''; search = ''; } clearGroupFilter(); searchResources();"
1298
+ class="btn btn-secondary">
1299
+ Clear
1300
+ </button>
1301
+ </div>
1302
+ </div>
1303
+ </div>
1304
+
1305
+ <!-- Selection Toolbar -->
1306
+ <div x-show="selectedResources.length > 0" x-transition
1307
+ class="bg-blue-50 border border-blue-200 rounded-lg p-3 flex items-center justify-between flex-shrink-0">
1308
+ <div class="flex items-center space-x-3">
1309
+ <span class="inline-flex items-center justify-center w-8 h-8 rounded-full bg-blue-600 text-white font-bold text-sm" x-text="selectedResources.length"></span>
1310
+ <span class="text-blue-800 font-medium">resource(s) selected</span>
1311
+ </div>
1312
+ <div class="flex items-center space-x-2">
1313
+ <button @click="openAddToGroupModal()" class="btn btn-primary btn-sm">
1314
+ <svg class="-ml-0.5 mr-1.5 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1315
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
1316
+ </svg>
1317
+ Add to Group
1318
+ </button>
1319
+ <button @click="selectedResources = []; if(window.resourceTable) window.resourceTable.deselectRow();" class="btn btn-secondary btn-sm">
1320
+ Clear Selection
1321
+ </button>
1322
+ </div>
1323
+ </div>
1324
+
1325
+ <!-- Results -->
1326
+ <div class="card flex-1 min-h-0 flex flex-col overflow-hidden">
1327
+ <div id="resource-table" class="flex-1 min-h-0 overflow-auto">
1328
+ <div class="p-12 text-center text-gray-500">
1329
+ <div class="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600 mx-auto"></div>
1330
+ <p class="mt-4 font-medium">Loading resources...</p>
1331
+ </div>
1332
+ </div>
1333
+ </div>
1334
+
1335
+ <!-- Column Customization Modal -->
1336
+ <div x-show="showColumnModal" x-cloak class="fixed z-10 inset-0 overflow-y-auto">
1337
+ <div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
1338
+ <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" @click="showColumnModal = false"></div>
1339
+ <div class="inline-block align-bottom bg-white rounded-xl px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full sm:p-6">
1340
+ <div class="flex items-center justify-between mb-4">
1341
+ <h3 class="text-lg font-semibold text-gray-900">Customize Columns</h3>
1342
+ <button @click="showColumnModal = false" class="text-gray-400 hover:text-gray-600">
1343
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1344
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
1345
+ </svg>
1346
+ </button>
1347
+ </div>
1348
+
1349
+ <!-- Legend -->
1350
+ <div class="flex items-center gap-6 text-xs text-gray-500 mb-4 pb-3 border-b">
1351
+ <div class="flex items-center gap-1.5">
1352
+ <div class="w-3 h-3 rounded border-2 border-blue-500 bg-blue-50"></div>
1353
+ <span>Visible</span>
1354
+ </div>
1355
+ <div class="flex items-center gap-1.5">
1356
+ <svg class="w-3.5 h-3.5 text-amber-500" fill="currentColor" viewBox="0 0 20 20">
1357
+ <path d="M5 4a2 2 0 012-2h6a2 2 0 012 2v14l-5-2.5L5 18V4z"/>
1358
+ </svg>
1359
+ <span>Frozen (sticky when scrolling)</span>
1360
+ </div>
1361
+ </div>
1362
+
1363
+ <div class="space-y-3 max-h-72 overflow-y-auto pr-2">
1364
+ <!-- Base Columns -->
1365
+ <div>
1366
+ <h4 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Resource Fields</h4>
1367
+ <div class="grid grid-cols-3 gap-x-4 gap-y-1">
1368
+ <template x-for="col in baseColumns" :key="col.field">
1369
+ <label class="flex items-center gap-1.5 py-0.5 cursor-pointer hover:bg-gray-50 rounded px-1 -mx-1">
1370
+ <input type="checkbox" :checked="col.visible" @change="toggleColumn(col.field)"
1371
+ class="h-3.5 w-3.5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
1372
+ <span class="text-xs truncate" :class="col.visible ? 'text-gray-900' : 'text-gray-500'" x-text="col.label"></span>
1373
+ <button @click.prevent="toggleFrozen(col.field)" x-show="col.visible"
1374
+ class="ml-auto flex-shrink-0"
1375
+ :class="col.frozen ? 'text-amber-500' : 'text-gray-300 hover:text-amber-400'"
1376
+ :title="col.frozen ? 'Unfreeze' : 'Freeze'">
1377
+ <svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
1378
+ <path d="M5 4a2 2 0 012-2h6a2 2 0 012 2v14l-5-2.5L5 18V4z"/>
1379
+ </svg>
1380
+ </button>
1381
+ </label>
1382
+ </template>
1383
+ </div>
1384
+ </div>
1385
+
1386
+ <!-- Tag Columns -->
1387
+ <div x-show="tagColumns.length > 0">
1388
+ <h4 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Tag Columns</h4>
1389
+ <p class="text-xs text-gray-400 mb-2">Show individual tag values as separate columns</p>
1390
+ <div class="grid grid-cols-3 gap-x-4 gap-y-1">
1391
+ <template x-for="col in tagColumns" :key="col.field">
1392
+ <label class="flex items-center gap-1.5 py-0.5 cursor-pointer hover:bg-gray-50 rounded px-1 -mx-1">
1393
+ <input type="checkbox" :checked="col.visible" @change="toggleColumn(col.field)"
1394
+ class="h-3.5 w-3.5 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
1395
+ <span class="text-xs truncate" :class="col.visible ? 'text-indigo-700' : 'text-gray-500'" x-text="col.tagKey"></span>
1396
+ <button @click.prevent="toggleFrozen(col.field)" x-show="col.visible"
1397
+ class="ml-auto flex-shrink-0"
1398
+ :class="col.frozen ? 'text-amber-500' : 'text-gray-300 hover:text-amber-400'"
1399
+ :title="col.frozen ? 'Unfreeze' : 'Freeze'">
1400
+ <svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
1401
+ <path d="M5 4a2 2 0 012-2h6a2 2 0 012 2v14l-5-2.5L5 18V4z"/>
1402
+ </svg>
1403
+ </button>
1404
+ </label>
1405
+ </template>
1406
+ </div>
1407
+ <p x-show="tagColumns.length === 0" class="text-xs text-gray-400 italic">No tags found in inventory</p>
1408
+ </div>
1409
+ </div>
1410
+
1411
+ <div class="mt-5 pt-4 border-t flex justify-end gap-3">
1412
+ <button @click="showColumnModal = false" class="btn btn-secondary">Cancel</button>
1413
+ <button @click="showColumnModal = false; rebuildTable()" class="btn btn-primary">Apply Changes</button>
1414
+ </div>
1415
+ </div>
1416
+ </div>
1417
+ </div>
1418
+
1419
+ <!-- Save Filter Modal -->
1420
+ <div x-show="showSaveFilterModal" x-cloak class="fixed z-10 inset-0 overflow-y-auto">
1421
+ <div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
1422
+ <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" @click="showSaveFilterModal = false"></div>
1423
+ <div class="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
1424
+ <h3 class="text-lg font-medium text-gray-900 mb-4">Save Filter</h3>
1425
+ <div class="space-y-4">
1426
+ <div>
1427
+ <label class="block text-sm font-medium text-gray-700">Name</label>
1428
+ <input type="text" x-model="filterName" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
1429
+ </div>
1430
+ <div>
1431
+ <label class="block text-sm font-medium text-gray-700">Description (optional)</label>
1432
+ <textarea x-model="filterDescription" rows="2" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"></textarea>
1433
+ </div>
1434
+ <div class="bg-gray-50 rounded-md p-3">
1435
+ <p class="text-xs text-gray-500 uppercase tracking-wide mb-2">Filter Configuration</p>
1436
+ <div x-show="advancedMode">
1437
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
1438
+ Advanced Filter
1439
+ </span>
1440
+ <p class="text-sm text-gray-700 mt-1">
1441
+ <span class="font-medium" x-text="filterRoot.logic"></span> logic with
1442
+ <span x-text="countConditions()"></span> condition(s)
1443
+ </p>
1444
+ <p class="text-xs text-gray-500 mt-1" x-show="filterRoot.items.some(i => i.type === 'group')">
1445
+ Includes nested groups
1446
+ </p>
1447
+ </div>
1448
+ <div x-show="!advancedMode">
1449
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
1450
+ Simple Filter
1451
+ </span>
1452
+ <ul class="mt-2 text-xs text-gray-600 space-y-1">
1453
+ <li x-show="selectedTypes.length > 0"><span class="font-medium">Types:</span> <span x-text="selectedTypes.join(', ')"></span></li>
1454
+ <li x-show="selectedRegions.length > 0"><span class="font-medium">Regions:</span> <span x-text="selectedRegions.join(', ')"></span></li>
1455
+ <li x-show="snapshot"><span class="font-medium">Snapshot:</span> <span x-text="snapshot"></span></li>
1456
+ <li x-show="search"><span class="font-medium">Search:</span> <span x-text="search"></span></li>
1457
+ <li x-show="selectedTypes.length === 0 && selectedRegions.length === 0 && !snapshot && !search" class="text-gray-400">No filters applied</li>
1458
+ </ul>
1459
+ </div>
1460
+ </div>
1461
+ </div>
1462
+ <div class="mt-5 pt-4 border-t sm:flex sm:flex-row-reverse gap-3">
1463
+ <button @click="saveFilter()" :disabled="!filterName.trim()" class="btn btn-primary disabled:opacity-50 w-full sm:w-auto">
1464
+ Save Filter
1465
+ </button>
1466
+ <button @click="showSaveFilterModal = false" class="btn btn-secondary mt-3 sm:mt-0 w-full sm:w-auto">
1467
+ Cancel
1468
+ </button>
1469
+ </div>
1470
+ </div>
1471
+ </div>
1472
+ </div>
1473
+
1474
+ <!-- Save View Modal -->
1475
+ <div x-show="showSaveViewModal" x-cloak class="fixed z-10 inset-0 overflow-y-auto">
1476
+ <div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
1477
+ <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" @click="showSaveViewModal = false"></div>
1478
+ <div class="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
1479
+ <h3 class="text-lg font-medium text-gray-900 mb-4">Save View</h3>
1480
+ <p class="text-sm text-gray-500 mb-4">Save the current column selection, sorting, and filters as a reusable view.</p>
1481
+ <div class="space-y-4">
1482
+ <div>
1483
+ <label class="block text-sm font-medium text-gray-700">Name</label>
1484
+ <input type="text" x-model="viewName" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
1485
+ </div>
1486
+ <div>
1487
+ <label class="block text-sm font-medium text-gray-700">Description (optional)</label>
1488
+ <textarea x-model="viewDescription" rows="2" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"></textarea>
1489
+ </div>
1490
+ <div class="bg-gray-50 rounded-md p-3">
1491
+ <p class="text-xs text-gray-500 uppercase tracking-wide mb-2">View Configuration</p>
1492
+ <div class="text-sm text-gray-700 space-y-1">
1493
+ <p><span class="font-medium">Columns:</span> <span x-text="visibleColumns.map(c => c.label).join(', ')"></span></p>
1494
+ <p><span class="font-medium">Sort:</span> <span x-text="sortBy + ' (' + sortOrder + ')'"></span></p>
1495
+ <div class="mt-2 pt-2 border-t border-gray-200">
1496
+ <p class="font-medium mb-1">Filters:</p>
1497
+ <div x-show="advancedMode">
1498
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
1499
+ Advanced (<span x-text="countConditions()"></span> conditions, <span x-text="filterRoot.logic"></span>)
1500
+ </span>
1501
+ </div>
1502
+ <div x-show="!advancedMode">
1503
+ <span x-show="selectedTypes.length > 0 || selectedRegions.length > 0 || snapshot || search" class="text-xs text-gray-600">
1504
+ <span x-show="selectedTypes.length > 0">Types: <span x-text="selectedTypes.join(', ')"></span></span>
1505
+ <span x-show="selectedTypes.length > 0 && (selectedRegions.length > 0 || snapshot || search)">, </span>
1506
+ <span x-show="selectedRegions.length > 0">Regions: <span x-text="selectedRegions.join(', ')"></span></span>
1507
+ <span x-show="selectedRegions.length > 0 && (snapshot || search)">, </span>
1508
+ <span x-show="snapshot">Snapshot: <span x-text="snapshot"></span></span>
1509
+ <span x-show="snapshot && search">, </span>
1510
+ <span x-show="search">Search: <span x-text="search"></span></span>
1511
+ </span>
1512
+ <span x-show="selectedTypes.length === 0 && selectedRegions.length === 0 && !snapshot && !search" class="text-xs text-gray-400">No filters</span>
1513
+ </div>
1514
+ </div>
1515
+ </div>
1516
+ </div>
1517
+ </div>
1518
+ <div class="mt-5 pt-4 border-t sm:flex sm:flex-row-reverse gap-3">
1519
+ <button @click="saveView()" :disabled="!viewName.trim()" class="btn btn-primary disabled:opacity-50 w-full sm:w-auto">
1520
+ Save View
1521
+ </button>
1522
+ <button @click="showSaveViewModal = false" class="btn btn-secondary mt-3 sm:mt-0 w-full sm:w-auto">
1523
+ Cancel
1524
+ </button>
1525
+ </div>
1526
+ </div>
1527
+ </div>
1528
+ </div>
1529
+
1530
+ <!-- Add to Group Modal -->
1531
+ <div x-show="showAddToGroupModal" x-cloak class="fixed z-10 inset-0 overflow-y-auto">
1532
+ <div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
1533
+ <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" @click="showAddToGroupModal = false"></div>
1534
+ <div class="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
1535
+ <div class="flex items-center mb-4">
1536
+ <div class="flex-shrink-0 h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center">
1537
+ <svg class="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1538
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
1539
+ </svg>
1540
+ </div>
1541
+ <div class="ml-3">
1542
+ <h3 class="text-lg font-medium text-gray-900">Add to Resource Group</h3>
1543
+ <p class="text-sm text-gray-500">Add <span x-text="selectedResources.length" class="font-semibold"></span> resource(s) to a group</p>
1544
+ </div>
1545
+ </div>
1546
+
1547
+ <div class="space-y-4">
1548
+ <!-- Select Group -->
1549
+ <div>
1550
+ <label class="block text-sm font-medium text-gray-700 mb-2">Select Group</label>
1551
+ <template x-if="availableGroups.length === 0">
1552
+ <div class="bg-gray-50 rounded-lg p-4 text-center">
1553
+ <svg class="mx-auto h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1554
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
1555
+ </svg>
1556
+ <p class="mt-2 text-sm text-gray-500">No groups available</p>
1557
+ <a href="/groups" class="mt-2 inline-block text-sm text-blue-600 hover:text-blue-800">Create a group first</a>
1558
+ </div>
1559
+ </template>
1560
+ <template x-if="availableGroups.length > 0">
1561
+ <div class="space-y-2 max-h-60 overflow-y-auto">
1562
+ <template x-for="group in availableGroups" :key="group.name">
1563
+ <label class="flex items-center p-3 rounded-lg border cursor-pointer transition-colors"
1564
+ :class="targetGroupName === group.name ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:bg-gray-50'">
1565
+ <input type="radio" name="targetGroup" :value="group.name" x-model="targetGroupName"
1566
+ class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300">
1567
+ <div class="ml-3 flex-1">
1568
+ <div class="flex items-center justify-between">
1569
+ <span class="text-sm font-medium text-gray-900" x-text="group.name"></span>
1570
+ <span class="text-xs text-gray-500"><span x-text="group.resource_count"></span> resources</span>
1571
+ </div>
1572
+ <p class="text-xs text-gray-500 mt-0.5" x-show="group.description" x-text="group.description"></p>
1573
+ </div>
1574
+ </label>
1575
+ </template>
1576
+ </div>
1577
+ </template>
1578
+ </div>
1579
+
1580
+ <!-- Selected Resources Preview -->
1581
+ <div class="bg-gray-50 rounded-lg p-3">
1582
+ <p class="text-xs text-gray-500 uppercase tracking-wide mb-2">Selected Resources</p>
1583
+ <div class="max-h-32 overflow-y-auto space-y-1">
1584
+ <template x-for="(resource, idx) in selectedResources.slice(0, 5)" :key="resource.arn">
1585
+ <div class="text-sm text-gray-700 flex items-center">
1586
+ <span class="w-4 h-4 flex items-center justify-center bg-blue-100 text-blue-600 rounded text-xs font-bold mr-2" x-text="idx + 1"></span>
1587
+ <span class="truncate" x-text="resource.name || resource.arn"></span>
1588
+ </div>
1589
+ </template>
1590
+ <p x-show="selectedResources.length > 5" class="text-xs text-gray-400 pl-6">
1591
+ ... and <span x-text="selectedResources.length - 5"></span> more
1592
+ </p>
1593
+ </div>
1594
+ </div>
1595
+
1596
+ <p class="text-xs text-gray-500">
1597
+ Resources are matched by Name + Type. Duplicates will be skipped.
1598
+ </p>
1599
+ </div>
1600
+
1601
+ <div class="mt-5 pt-4 border-t sm:flex sm:flex-row-reverse gap-3">
1602
+ <button @click="addSelectedToGroup()" :disabled="!targetGroupName || addToGroupLoading"
1603
+ class="btn btn-primary disabled:opacity-50 w-full sm:w-auto">
1604
+ <span x-show="addToGroupLoading" class="mr-2">
1605
+ <svg class="animate-spin h-4 w-4 text-white inline" fill="none" viewBox="0 0 24 24">
1606
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
1607
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
1608
+ </svg>
1609
+ </span>
1610
+ Add to Group
1611
+ </button>
1612
+ <button @click="showAddToGroupModal = false" class="btn btn-secondary mt-3 sm:mt-0 w-full sm:w-auto">
1613
+ Cancel
1614
+ </button>
1615
+ </div>
1616
+ </div>
1617
+ </div>
1618
+ </div>
1619
+ </div>
1620
+ {% endblock %}
1621
+
1622
+ {% block scripts %}
1623
+ <script>
1624
+ // Tabulator instance
1625
+ let resourceTable = null;
1626
+
1627
+ // Wait for Alpine to be ready before initializing
1628
+ document.addEventListener('DOMContentLoaded', function() {
1629
+ requestAnimationFrame(function() {
1630
+ requestAnimationFrame(function() {
1631
+ searchResources();
1632
+ loadTagKeys();
1633
+ });
1634
+ });
1635
+ });
1636
+
1637
+ document.body.addEventListener('htmx:afterSwap', function(evt) {
1638
+ if (evt.detail.target.id === 'saved-views-list') {
1639
+ const data = JSON.parse(evt.detail.xhr.responseText);
1640
+ renderSavedViewsChips(data.views || [], evt.detail.target);
1641
+ }
1642
+ if (evt.detail.target.id === 'saved-filters-list') {
1643
+ const data = JSON.parse(evt.detail.xhr.responseText);
1644
+ renderSavedFiltersChips(data.filters || [], evt.detail.target);
1645
+ }
1646
+ });
1647
+
1648
+ // Check if filter tree has any tag conditions
1649
+ function hasTagConditionsInGroup(group) {
1650
+ if (!group || !group.items) return false;
1651
+ // Creator fields that read from tags object
1652
+ const creatorFields = ['_created_by', '_created_by_type', '_created_at'];
1653
+ return group.items.some(item => {
1654
+ if (item.type === 'group') {
1655
+ return hasTagConditionsInGroup(item);
1656
+ } else {
1657
+ return item.field === 'tag_key' || item.field === 'tag_value' ||
1658
+ item.field.startsWith('tag:') || creatorFields.includes(item.field);
1659
+ }
1660
+ });
1661
+ }
1662
+
1663
+ function searchResources() {
1664
+ const data = Alpine.$data(document.querySelector('[x-data]'));
1665
+
1666
+ // Clear selected resources when searching
1667
+ data.selectedResources = [];
1668
+
1669
+ // Check if using group filter
1670
+ if (data.groupFilterMode !== 'none' && data.selectedGroupFilter) {
1671
+ searchResourcesWithGroupFilter(data);
1672
+ return;
1673
+ }
1674
+
1675
+ const params = new URLSearchParams();
1676
+
1677
+ const tagsColumnVisible = data.columns.find(c => c.field === 'tags')?.visible;
1678
+ const anyTagColumnVisible = data.columns.some(c => (c.field.startsWith('tag:') || c.isTagField) && c.visible);
1679
+ const hasTagConditions = data.advancedMode && hasTagConditionsInGroup(data.filterRoot);
1680
+
1681
+ const useClientSideFilter = data.advancedMode ||
1682
+ data.selectedTypes.length > 1 ||
1683
+ data.selectedRegions.length > 1;
1684
+
1685
+ if (!useClientSideFilter) {
1686
+ if (data.search) params.append('q', data.search);
1687
+ if (data.selectedTypes.length === 1) params.append('type', data.selectedTypes[0]);
1688
+ if (data.selectedRegions.length === 1) params.append('region', data.selectedRegions[0]);
1689
+ if (data.snapshot) params.append('snapshot', data.snapshot);
1690
+ } else if (!data.advancedMode) {
1691
+ if (data.snapshot) params.append('snapshot', data.snapshot);
1692
+ }
1693
+
1694
+ if (tagsColumnVisible || anyTagColumnVisible || hasTagConditions) {
1695
+ params.append('include_tags', 'true');
1696
+ }
1697
+
1698
+ params.append('limit', '500');
1699
+
1700
+ fetch('/api/resources?' + params.toString())
1701
+ .then(r => r.json())
1702
+ .then(result => {
1703
+ data.allResources = result;
1704
+
1705
+ if (data.advancedMode) {
1706
+ data.applyAdvancedFilter();
1707
+ } else if (useClientSideFilter) {
1708
+ data.applySimpleFilter();
1709
+ } else {
1710
+ data.resourceData = result;
1711
+ data.applySort();
1712
+ }
1713
+ renderTable();
1714
+ });
1715
+ }
1716
+
1717
+ function searchResourcesWithGroupFilter(data) {
1718
+ const groupName = encodeURIComponent(data.selectedGroupFilter);
1719
+ const endpoint = data.groupFilterMode === 'in_group'
1720
+ ? `/api/groups/${groupName}/resources/in`
1721
+ : `/api/groups/${groupName}/resources/not-in`;
1722
+
1723
+ const params = new URLSearchParams();
1724
+ if (data.snapshot) {
1725
+ params.append('snapshot', data.snapshot);
1726
+ }
1727
+ params.append('limit', '500');
1728
+
1729
+ const tagsColumnVisible = data.columns.find(c => c.field === 'tags')?.visible;
1730
+ const anyTagColumnVisible = data.columns.some(c => (c.field.startsWith('tag:') || c.isTagField) && c.visible);
1731
+ if (tagsColumnVisible || anyTagColumnVisible) {
1732
+ params.append('include_tags', 'true');
1733
+ }
1734
+
1735
+ fetch(`${endpoint}?${params.toString()}`)
1736
+ .then(r => r.json())
1737
+ .then(result => {
1738
+ // Apply additional client-side filters if needed
1739
+ let resources = result.resources || [];
1740
+
1741
+ // Apply search filter
1742
+ if (data.search) {
1743
+ const searchLower = data.search.toLowerCase();
1744
+ resources = resources.filter(r =>
1745
+ (r.name && r.name.toLowerCase().includes(searchLower)) ||
1746
+ (r.arn && r.arn.toLowerCase().includes(searchLower))
1747
+ );
1748
+ }
1749
+
1750
+ // Apply type filter
1751
+ if (data.selectedTypes.length > 0) {
1752
+ resources = resources.filter(r => data.selectedTypes.includes(r.resource_type));
1753
+ }
1754
+
1755
+ // Apply region filter
1756
+ if (data.selectedRegions.length > 0) {
1757
+ resources = resources.filter(r => data.selectedRegions.includes(r.region));
1758
+ }
1759
+
1760
+ data.allResources = { resources: resources, count: resources.length };
1761
+ data.resourceData = { resources: resources, count: resources.length };
1762
+ data.applySort();
1763
+ renderTable();
1764
+ });
1765
+ }
1766
+
1767
+ // Build Tabulator column definitions from Alpine data
1768
+ function buildColumns() {
1769
+ const data = Alpine.$data(document.querySelector('[x-data]'));
1770
+ const columns = [];
1771
+
1772
+ data.visibleColumns.forEach(col => {
1773
+ const colDef = {
1774
+ title: col.label,
1775
+ field: col.field,
1776
+ minWidth: col.width || 100,
1777
+ resizable: true,
1778
+ frozen: col.frozen || false,
1779
+ headerSort: true,
1780
+ tooltip: true
1781
+ };
1782
+
1783
+ // Custom formatters for different field types
1784
+ if (col.field === 'name') {
1785
+ colDef.formatter = function(cell) {
1786
+ const value = cell.getValue() || 'N/A';
1787
+ const initial = (value || 'N').charAt(0).toUpperCase();
1788
+ return `<div class="flex items-center">
1789
+ <div class="flex-shrink-0 h-7 w-7 bg-gradient-to-br from-blue-400 to-blue-600 rounded-lg flex items-center justify-center">
1790
+ <span class="text-white text-xs font-bold">${initial}</span>
1791
+ </div>
1792
+ <span class="ml-2.5 font-medium text-gray-900 truncate">${value}</span>
1793
+ </div>`;
1794
+ };
1795
+ colDef.cssClass = "tabulator-cell-name";
1796
+ } else if (col.field === 'arn') {
1797
+ colDef.formatter = function(cell) {
1798
+ const value = cell.getValue() || 'N/A';
1799
+ const escaped = value.replace(/'/g, "\\'");
1800
+ return `<div class="flex items-center gap-2">
1801
+ <code class="text-xs text-gray-500 truncate font-mono bg-gray-100/80 px-1.5 py-0.5 rounded flex-1">${value}</code>
1802
+ <button onclick="copyToClipboard('${escaped}')" class="flex-shrink-0 p-1 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors" title="Copy ARN">
1803
+ <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1804
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
1805
+ </svg>
1806
+ </button>
1807
+ </div>`;
1808
+ };
1809
+ } else if (col.field === 'resource_type') {
1810
+ colDef.formatter = function(cell) {
1811
+ const value = cell.getValue() || 'N/A';
1812
+ const colorClass = getTypeBadgeClass(value);
1813
+ return `<span class="badge ${colorClass}">${value}</span>`;
1814
+ };
1815
+ } else if (col.field === 'region') {
1816
+ colDef.formatter = function(cell) {
1817
+ const value = cell.getValue() || 'N/A';
1818
+ return `<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-slate-100 text-slate-700">
1819
+ <svg class="w-3 h-3 mr-1 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1820
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
1821
+ </svg>
1822
+ ${value}
1823
+ </span>`;
1824
+ };
1825
+ } else if (col.field === 'tags') {
1826
+ colDef.formatter = function(cell) {
1827
+ const row = cell.getRow().getData();
1828
+ const tags = row.tags || {};
1829
+ const entries = Object.entries(tags);
1830
+ if (entries.length === 0) return '<span class="text-gray-400 text-xs italic">No tags</span>';
1831
+
1832
+ let html = '<div class="flex flex-wrap gap-1">';
1833
+ entries.slice(0, 3).forEach(([key, val]) => {
1834
+ const k = key.length > 8 ? key.substring(0, 8) + '…' : key;
1835
+ const v = String(val).length > 10 ? String(val).substring(0, 10) + '…' : val;
1836
+ html += `<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-emerald-50 text-emerald-700 border border-emerald-200" title="${key}=${val}">
1837
+ <span class="font-medium">${k}:</span><span class="ml-0.5">${v}</span>
1838
+ </span>`;
1839
+ });
1840
+ if (entries.length > 3) {
1841
+ html += `<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-500">+${entries.length - 3}</span>`;
1842
+ }
1843
+ html += '</div>';
1844
+ return html;
1845
+ };
1846
+ } else if (col.field === '_created_by') {
1847
+ // Created By column - reads from tags._created_by
1848
+ colDef.formatter = function(cell) {
1849
+ const row = cell.getRow().getData();
1850
+ const tags = row.tags || {};
1851
+ const value = tags['_created_by'];
1852
+ if (!value) return '<span class="text-gray-300">—</span>';
1853
+ // Extract role/user name from ARN for display
1854
+ let display = value;
1855
+ if (value.includes(':role/')) {
1856
+ display = value.split(':role/')[1] || value;
1857
+ } else if (value.includes(':user/')) {
1858
+ display = value.split(':user/')[1] || value;
1859
+ } else if (value.startsWith('service:')) {
1860
+ display = value.replace('service:', '');
1861
+ }
1862
+ const truncated = display.length > 30 ? display.substring(0, 30) + '…' : display;
1863
+ return `<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-50 text-purple-700 border border-purple-200" title="${value}">${truncated}</span>`;
1864
+ };
1865
+ // Custom sorter for tag-based field
1866
+ colDef.sorter = function(a, b, aRow, bRow, column, dir, sorterParams) {
1867
+ const aVal = (aRow.getData().tags || {})['_created_by'] || '';
1868
+ const bVal = (bRow.getData().tags || {})['_created_by'] || '';
1869
+ return aVal.localeCompare(bVal);
1870
+ };
1871
+ } else if (col.field === '_created_by_type') {
1872
+ // Creator Type column - reads from tags._created_by_type
1873
+ colDef.formatter = function(cell) {
1874
+ const row = cell.getRow().getData();
1875
+ const tags = row.tags || {};
1876
+ const value = tags['_created_by_type'];
1877
+ if (!value) return '<span class="text-gray-300">—</span>';
1878
+ const colors = {
1879
+ 'AssumedRole': 'bg-blue-50 text-blue-700 border-blue-200',
1880
+ 'IAMUser': 'bg-green-50 text-green-700 border-green-200',
1881
+ 'Root': 'bg-red-50 text-red-700 border-red-200',
1882
+ 'AWSService': 'bg-orange-50 text-orange-700 border-orange-200'
1883
+ };
1884
+ const colorClass = colors[value] || 'bg-gray-50 text-gray-700 border-gray-200';
1885
+ return `<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${colorClass} border">${value}</span>`;
1886
+ };
1887
+ // Custom sorter for tag-based field
1888
+ colDef.sorter = function(a, b, aRow, bRow, column, dir, sorterParams) {
1889
+ const aVal = (aRow.getData().tags || {})['_created_by_type'] || '';
1890
+ const bVal = (bRow.getData().tags || {})['_created_by_type'] || '';
1891
+ return aVal.localeCompare(bVal);
1892
+ };
1893
+ } else if (col.field === '_created_at') {
1894
+ // Creation Time column - reads from tags._created_at
1895
+ colDef.formatter = function(cell) {
1896
+ const row = cell.getRow().getData();
1897
+ const tags = row.tags || {};
1898
+ const value = tags['_created_at'];
1899
+ if (!value) return '<span class="text-gray-300">—</span>';
1900
+ try {
1901
+ const date = new Date(value);
1902
+ const formatted = date.toLocaleString();
1903
+ return `<span class="text-xs text-gray-600" title="${value}">${formatted}</span>`;
1904
+ } catch (e) {
1905
+ return `<span class="text-xs text-gray-600">${value}</span>`;
1906
+ }
1907
+ };
1908
+ // Custom sorter for date field in tags
1909
+ colDef.sorter = function(a, b, aRow, bRow, column, dir, sorterParams) {
1910
+ const aVal = (aRow.getData().tags || {})['_created_at'] || '';
1911
+ const bVal = (bRow.getData().tags || {})['_created_at'] || '';
1912
+ // Sort as dates
1913
+ const aDate = aVal ? new Date(aVal).getTime() : 0;
1914
+ const bDate = bVal ? new Date(bVal).getTime() : 0;
1915
+ return aDate - bDate;
1916
+ };
1917
+ } else if (col.field.startsWith('tag:')) {
1918
+ const tagKey = col.field.substring(4);
1919
+ colDef.formatter = function(cell) {
1920
+ const row = cell.getRow().getData();
1921
+ const tags = row.tags || {};
1922
+ const value = tags[tagKey];
1923
+ if (!value) return '<span class="text-gray-300">—</span>';
1924
+ const display = String(value).length > 20 ? String(value).substring(0, 20) + '…' : value;
1925
+ return `<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-50 text-indigo-700 border border-indigo-200" title="${value}">${display}</span>`;
1926
+ };
1927
+ // Custom sorter for tag column - must use closure to capture tagKey
1928
+ (function(key) {
1929
+ colDef.sorter = function(a, b, aRow, bRow, column, dir, sorterParams) {
1930
+ const aVal = (aRow.getData().tags || {})[key] || '';
1931
+ const bVal = (bRow.getData().tags || {})[key] || '';
1932
+ return String(aVal).localeCompare(String(bVal));
1933
+ };
1934
+ })(tagKey);
1935
+ }
1936
+
1937
+ columns.push(colDef);
1938
+ });
1939
+
1940
+ return columns;
1941
+ }
1942
+
1943
+ // Get badge class based on resource type
1944
+ function getTypeBadgeClass(type) {
1945
+ if (!type) return 'badge-default';
1946
+ const t = type.toLowerCase();
1947
+ if (t.includes('s3')) return 'badge-s3';
1948
+ if (t.includes('ec2') || t.includes('instance')) return 'badge-ec2';
1949
+ if (t.includes('lambda')) return 'badge-lambda';
1950
+ if (t.includes('iam')) return 'badge-iam';
1951
+ if (t.includes('rds') || t.includes('database')) return 'badge-rds';
1952
+ if (t.includes('dynamodb')) return 'badge-dynamodb';
1953
+ return 'badge-default';
1954
+ }
1955
+
1956
+ function renderTable() {
1957
+ const data = Alpine.$data(document.querySelector('[x-data]'));
1958
+ const target = document.getElementById('resource-table');
1959
+
1960
+ if (!data.resourceData || !data.resourceData.resources || data.resourceData.resources.length === 0) {
1961
+ if (resourceTable) {
1962
+ resourceTable.destroy();
1963
+ resourceTable = null;
1964
+ }
1965
+ target.innerHTML = `
1966
+ <div class="p-12 text-center text-gray-500">
1967
+ <svg class="mx-auto h-16 w-16 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1968
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"/>
1969
+ </svg>
1970
+ <p class="mt-4 text-lg font-medium">No resources found</p>
1971
+ <p class="mt-1 text-sm text-gray-400">Try adjusting your search or filter criteria</p>
1972
+ </div>
1973
+ `;
1974
+ return;
1975
+ }
1976
+
1977
+ // Clear container for Tabulator
1978
+ target.innerHTML = '';
1979
+
1980
+ const columns = buildColumns();
1981
+
1982
+ // Add row selection column at the beginning
1983
+ const selectColumn = {
1984
+ formatter: "rowSelection",
1985
+ titleFormatter: "rowSelection",
1986
+ hozAlign: "center",
1987
+ headerSort: false,
1988
+ width: 50,
1989
+ minWidth: 50,
1990
+ maxWidth: 50,
1991
+ frozen: true,
1992
+ resizable: false,
1993
+ cellClick: function(e, cell) {
1994
+ cell.getRow().toggleSelect();
1995
+ }
1996
+ };
1997
+
1998
+ // Create or update Tabulator
1999
+ resourceTable = new Tabulator(target, {
2000
+ data: data.resourceData.resources,
2001
+ columns: [selectColumn, ...columns],
2002
+ layout: "fitData",
2003
+ height: "100%",
2004
+ movableColumns: true,
2005
+ resizableColumns: true,
2006
+ placeholder: "No resources to display",
2007
+ initialSort: [{ column: data.sortBy, dir: data.sortOrder }],
2008
+ selectableRows: true,
2009
+ selectableRowsRangeMode: "click",
2010
+
2011
+ // Row selection changed
2012
+ rowSelectionChanged: function(data, rows) {
2013
+ const alpineData = Alpine.$data(document.querySelector('[x-data]'));
2014
+ alpineData.updateSelectedResources(data);
2015
+ },
2016
+
2017
+ // Column events
2018
+ columnMoved: function(column, columns) {
2019
+ // Update Alpine column order when user drags columns
2020
+ // Filter out the selection column
2021
+ const newOrder = columns.filter(c => c.getField()).map(c => c.getField());
2022
+ reorderAlpineColumns(newOrder);
2023
+ },
2024
+
2025
+ columnResized: function(column) {
2026
+ // Update Alpine column width
2027
+ const field = column.getField();
2028
+ if (!field) return; // Skip selection column
2029
+ const width = column.getWidth();
2030
+ const alpineData = Alpine.$data(document.querySelector('[x-data]'));
2031
+ alpineData.setColumnWidth(field, width);
2032
+ },
2033
+
2034
+ // Sorting
2035
+ dataSorted: function(sorters, rows) {
2036
+ if (sorters.length > 0) {
2037
+ const alpineData = Alpine.$data(document.querySelector('[x-data]'));
2038
+ alpineData.sortBy = sorters[0].field;
2039
+ alpineData.sortOrder = sorters[0].dir;
2040
+ }
2041
+ }
2042
+ });
2043
+
2044
+ // Make table globally accessible
2045
+ window.resourceTable = resourceTable;
2046
+
2047
+ // Add footer with count
2048
+ const footer = document.createElement('div');
2049
+ footer.className = 'bg-gradient-to-r from-slate-50 to-slate-100 px-4 py-3 flex items-center justify-between border-t border-slate-200 rounded-b-xl';
2050
+ footer.innerHTML = `
2051
+ <div class="flex items-center gap-4">
2052
+ <p class="text-sm text-slate-600">
2053
+ Showing <span class="font-semibold text-slate-900">${data.resourceData.resources.length}</span>
2054
+ of <span class="font-semibold text-slate-900">${data.resourceData.count}</span> resources
2055
+ </p>
2056
+ </div>
2057
+ <div class="flex items-center gap-3 text-xs text-slate-400">
2058
+ <span class="flex items-center gap-1">
2059
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
2060
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
2061
+ </svg>
2062
+ Click to select
2063
+ </span>
2064
+ <span>•</span>
2065
+ <span class="flex items-center gap-1">
2066
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
2067
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l4-4 4 4m0 6l-4 4-4-4"/>
2068
+ </svg>
2069
+ Click headers to sort
2070
+ </span>
2071
+ <span>•</span>
2072
+ <span class="flex items-center gap-1">
2073
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
2074
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"/>
2075
+ </svg>
2076
+ Drag to resize
2077
+ </span>
2078
+ </div>
2079
+ `;
2080
+ target.appendChild(footer);
2081
+ }
2082
+
2083
+ // Reorder Alpine columns to match Tabulator drag order
2084
+ function reorderAlpineColumns(newOrder) {
2085
+ const data = Alpine.$data(document.querySelector('[x-data]'));
2086
+
2087
+ // Create new ordered arrays
2088
+ const newBaseColumns = [];
2089
+ const newTagColumns = [];
2090
+
2091
+ newOrder.forEach(field => {
2092
+ const baseCol = data.baseColumns.find(c => c.field === field);
2093
+ if (baseCol) {
2094
+ newBaseColumns.push(baseCol);
2095
+ } else {
2096
+ const tagCol = data.tagColumns.find(c => c.field === field);
2097
+ if (tagCol) newTagColumns.push(tagCol);
2098
+ }
2099
+ });
2100
+
2101
+ // Add non-visible columns at the end
2102
+ data.baseColumns.forEach(col => {
2103
+ if (!col.visible && !newBaseColumns.find(c => c.field === col.field)) {
2104
+ newBaseColumns.push(col);
2105
+ }
2106
+ });
2107
+ data.tagColumns.forEach(col => {
2108
+ if (!col.visible && !newTagColumns.find(c => c.field === col.field)) {
2109
+ newTagColumns.push(col);
2110
+ }
2111
+ });
2112
+
2113
+ data.baseColumns = newBaseColumns;
2114
+ data.tagColumns = newTagColumns;
2115
+ }
2116
+
2117
+ // Rebuild table when columns change (from modal)
2118
+ function rebuildTable() {
2119
+ searchResources();
2120
+ }
2121
+
2122
+ // Copy to clipboard helper
2123
+ function copyToClipboard(text) {
2124
+ navigator.clipboard.writeText(text).then(() => {
2125
+ // Brief toast feedback would go here
2126
+ });
2127
+ }
2128
+
2129
+ function renderSavedViewsChips(views, target) {
2130
+ if (views.length === 0) {
2131
+ target.innerHTML = '<span class="text-gray-400 text-sm">No saved views yet</span>';
2132
+ return;
2133
+ }
2134
+
2135
+ let html = '';
2136
+ views.forEach(v => {
2137
+ html += `
2138
+ <button onclick="loadView(${v.id})"
2139
+ class="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium bg-gradient-to-r from-purple-100 to-purple-50 text-purple-800 hover:from-purple-200 hover:to-purple-100 transition-all shadow-sm group">
2140
+ ${v.is_default ? '<svg class="w-3 h-3 mr-1 text-purple-600" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg>' : ''}
2141
+ ${v.name}
2142
+ <span onclick="event.stopPropagation(); deleteView(${v.id})" class="ml-1.5 opacity-0 group-hover:opacity-100 text-purple-600 hover:text-purple-900">
2143
+ <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
2144
+ </span>
2145
+ </button>
2146
+ `;
2147
+ });
2148
+ target.innerHTML = html;
2149
+ }
2150
+
2151
+ function countFilterConditions(config) {
2152
+ if (config.version === 2 || config.items) {
2153
+ function countInGroup(group) {
2154
+ let count = 0;
2155
+ for (const item of group.items || []) {
2156
+ if (item.type === 'group') count += countInGroup(item);
2157
+ else count++;
2158
+ }
2159
+ return count;
2160
+ }
2161
+ return countInGroup(config);
2162
+ } else if (config.conditions) {
2163
+ return config.conditions.length;
2164
+ }
2165
+ return 0;
2166
+ }
2167
+
2168
+ function renderSavedFiltersChips(filters, target) {
2169
+ if (filters.length === 0) {
2170
+ target.innerHTML = '<span class="text-gray-400 text-sm">No saved filters yet</span>';
2171
+ return;
2172
+ }
2173
+
2174
+ let html = '';
2175
+ filters.forEach(f => {
2176
+ const config = f.filter_config || {};
2177
+ const isAdvanced = config.version === 2 || config.items || (config.conditions && config.conditions.length > 0);
2178
+ const conditionCount = countFilterConditions(config);
2179
+
2180
+ html += `
2181
+ <button onclick="loadFilter(${f.id}, ${JSON.stringify(config).replace(/"/g, '&quot;')})"
2182
+ class="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium ${isAdvanced ? 'bg-gradient-to-r from-emerald-100 to-emerald-50 text-emerald-800 hover:from-emerald-200 hover:to-emerald-100' : 'bg-gradient-to-r from-blue-100 to-blue-50 text-blue-800 hover:from-blue-200 hover:to-blue-100'} transition-all shadow-sm group"
2183
+ title="${isAdvanced ? (config.logic || 'AND') + ' - ' + conditionCount + ' condition(s)' : 'Simple filter'}">
2184
+ ${isAdvanced ? '<svg class="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>' : ''}
2185
+ ${f.name}
2186
+ ${isAdvanced ? '<span class="ml-1 text-xs opacity-75">(' + conditionCount + ')</span>' : ''}
2187
+ <span onclick="event.stopPropagation(); deleteFilter(${f.id})" class="ml-1.5 opacity-0 group-hover:opacity-100 ${isAdvanced ? 'text-emerald-600 hover:text-emerald-900' : 'text-blue-600 hover:text-blue-900'}">
2188
+ <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
2189
+ </span>
2190
+ </button>
2191
+ `;
2192
+ });
2193
+ target.innerHTML = html;
2194
+ }
2195
+
2196
+ function loadView(id) {
2197
+ fetch(`/api/views/${id}`)
2198
+ .then(r => r.json())
2199
+ .then(view => {
2200
+ const data = Alpine.$data(document.querySelector('[x-data]'));
2201
+ const config = view.view_config;
2202
+
2203
+ if (config.columns) {
2204
+ data.baseColumns.forEach(col => {
2205
+ const saved = config.columns.find(c => c.field === col.field);
2206
+ if (saved) {
2207
+ col.visible = saved.visible;
2208
+ if (saved.width) col.width = saved.width;
2209
+ if (saved.frozen !== undefined) col.frozen = saved.frozen;
2210
+ } else {
2211
+ col.visible = false;
2212
+ }
2213
+ });
2214
+ data.tagColumns.forEach(col => {
2215
+ const saved = config.columns.find(c => c.field === col.field);
2216
+ if (saved) {
2217
+ col.visible = saved.visible;
2218
+ if (saved.width) col.width = saved.width;
2219
+ if (saved.frozen !== undefined) col.frozen = saved.frozen;
2220
+ } else {
2221
+ col.visible = false;
2222
+ }
2223
+ });
2224
+ }
2225
+
2226
+ if (config.sort_by) data.sortBy = config.sort_by;
2227
+ if (config.sort_order) data.sortOrder = config.sort_order;
2228
+ if (config.filters) data.loadFilterConfig(config.filters);
2229
+
2230
+ data.currentViewId = id;
2231
+ fetch(`/api/views/${id}/use`, { method: 'POST' });
2232
+ searchResources();
2233
+ });
2234
+ }
2235
+
2236
+ function loadFilter(id, config) {
2237
+ const data = Alpine.$data(document.querySelector('[x-data]'));
2238
+ data.loadFilterConfig(config);
2239
+ fetch(`/api/filters/${id}/use`, { method: 'POST' });
2240
+ searchResources();
2241
+ }
2242
+
2243
+ function saveFilter() {
2244
+ const data = Alpine.$data(document.querySelector('[x-data]'));
2245
+ const filterConfig = data.getFilterConfig();
2246
+
2247
+ fetch('/api/filters', {
2248
+ method: 'POST',
2249
+ headers: { 'Content-Type': 'application/json' },
2250
+ body: JSON.stringify({
2251
+ name: data.filterName,
2252
+ description: data.filterDescription,
2253
+ filter_config: filterConfig
2254
+ })
2255
+ })
2256
+ .then(r => r.json())
2257
+ .then(() => {
2258
+ data.showSaveFilterModal = false;
2259
+ htmx.trigger('#saved-filters-list', 'load');
2260
+ });
2261
+ }
2262
+
2263
+ function saveView() {
2264
+ const data = Alpine.$data(document.querySelector('[x-data]'));
2265
+ const filters = data.getFilterConfig();
2266
+
2267
+ fetch('/api/views', {
2268
+ method: 'POST',
2269
+ headers: { 'Content-Type': 'application/json' },
2270
+ body: JSON.stringify({
2271
+ name: data.viewName,
2272
+ description: data.viewDescription,
2273
+ view_config: {
2274
+ columns: data.columns.map(c => ({
2275
+ field: c.field,
2276
+ label: c.label,
2277
+ visible: c.visible,
2278
+ width: c.width || 150,
2279
+ frozen: c.frozen || false
2280
+ })),
2281
+ sort_by: data.sortBy,
2282
+ sort_order: data.sortOrder,
2283
+ filters: filters
2284
+ }
2285
+ })
2286
+ })
2287
+ .then(r => r.json())
2288
+ .then(() => {
2289
+ data.showSaveViewModal = false;
2290
+ data.viewName = '';
2291
+ data.viewDescription = '';
2292
+ htmx.trigger('#saved-views-list', 'load');
2293
+ });
2294
+ }
2295
+
2296
+ function deleteFilter(id) {
2297
+ if (!confirm('Delete this saved filter?')) return;
2298
+ fetch(`/api/filters/${id}`, { method: 'DELETE' })
2299
+ .then(() => htmx.trigger('#saved-filters-list', 'load'));
2300
+ }
2301
+
2302
+ function deleteView(id) {
2303
+ if (!confirm('Delete this saved view?')) return;
2304
+ fetch(`/api/views/${id}`, { method: 'DELETE' })
2305
+ .then(() => htmx.trigger('#saved-views-list', 'load'));
2306
+ }
2307
+
2308
+ function exportCSV() {
2309
+ // Export the currently visible/filtered data from the table
2310
+ if (!window.resourceTable) {
2311
+ alert('No data to export');
2312
+ return;
2313
+ }
2314
+
2315
+ const data = Alpine.$data(document.querySelector('[x-data]'));
2316
+ const visibleColumns = data.visibleColumns.map(c => c.field);
2317
+
2318
+ // Get data from table (respects current filters/sorting)
2319
+ const tableData = window.resourceTable.getData();
2320
+
2321
+ if (tableData.length === 0) {
2322
+ alert('No data to export');
2323
+ return;
2324
+ }
2325
+
2326
+ // Build CSV content
2327
+ const headers = visibleColumns.filter(f => f !== 'tags'); // Skip complex objects
2328
+ let csv = headers.join(',') + '\n';
2329
+
2330
+ tableData.forEach(row => {
2331
+ const values = headers.map(field => {
2332
+ let val = row[field];
2333
+ if (val === null || val === undefined) val = '';
2334
+ // Handle tag: fields
2335
+ if (field.startsWith('tag:') && row.tags) {
2336
+ const tagKey = field.substring(4);
2337
+ val = row.tags[tagKey] || '';
2338
+ }
2339
+ // Escape quotes and wrap in quotes if contains comma
2340
+ val = String(val).replace(/"/g, '""');
2341
+ if (val.includes(',') || val.includes('"') || val.includes('\n')) {
2342
+ val = '"' + val + '"';
2343
+ }
2344
+ return val;
2345
+ });
2346
+ csv += values.join(',') + '\n';
2347
+ });
2348
+
2349
+ // Download
2350
+ const blob = new Blob([csv], { type: 'text/csv' });
2351
+ const url = URL.createObjectURL(blob);
2352
+ const a = document.createElement('a');
2353
+ a.href = url;
2354
+ a.download = 'resources_export_' + new Date().toISOString().slice(0,19).replace(/[:-]/g,'') + '.csv';
2355
+ a.click();
2356
+ URL.revokeObjectURL(url);
2357
+ }
2358
+
2359
+ function exportYAML() {
2360
+ // Use server-side export to get ALL properties including raw_config
2361
+ console.log('exportYAML called');
2362
+
2363
+ try {
2364
+ // Build query params from current filter state
2365
+ const data = Alpine.$data(document.querySelector('[x-data]'));
2366
+ const params = new URLSearchParams();
2367
+
2368
+ // Add basic filters (property names match Alpine data model)
2369
+ if (data && data.search) params.set('q', data.search);
2370
+ if (data && data.snapshot) params.set('snapshot', data.snapshot);
2371
+ // Arrays - pass as comma-separated (API supports multiple)
2372
+ if (data && data.selectedTypes && data.selectedTypes.length > 0) {
2373
+ params.set('type', data.selectedTypes.join(','));
2374
+ }
2375
+ if (data && data.selectedRegions && data.selectedRegions.length > 0) {
2376
+ params.set('region', data.selectedRegions.join(','));
2377
+ }
2378
+
2379
+ // Include full config and tags (server defaults to true, but be explicit)
2380
+ params.set('include_config', 'true');
2381
+ params.set('include_tags', 'true');
2382
+
2383
+ // Fetch from server API
2384
+ const url = '/api/resources/export/yaml?' + params.toString();
2385
+ console.log('Fetching YAML from:', url);
2386
+
2387
+ fetch(url)
2388
+ .then(response => {
2389
+ console.log('Response status:', response.status);
2390
+ if (!response.ok) {
2391
+ return response.json().then(err => {
2392
+ throw new Error(err.detail || 'Export failed');
2393
+ });
2394
+ }
2395
+ return response.blob();
2396
+ })
2397
+ .then(blob => {
2398
+ console.log('Blob received, size:', blob.size);
2399
+ const downloadUrl = URL.createObjectURL(blob);
2400
+ const a = document.createElement('a');
2401
+ a.href = downloadUrl;
2402
+ a.download = 'resources_export_' + new Date().toISOString().slice(0,19).replace(/[:-]/g,'') + '.yaml';
2403
+ document.body.appendChild(a);
2404
+ a.click();
2405
+ document.body.removeChild(a);
2406
+ URL.revokeObjectURL(downloadUrl);
2407
+ console.log('Download triggered');
2408
+ })
2409
+ .catch(error => {
2410
+ console.error('Export error:', error);
2411
+ alert('Export failed: ' + error.message);
2412
+ });
2413
+ } catch (e) {
2414
+ console.error('exportYAML error:', e);
2415
+ alert('Export error: ' + e.message);
2416
+ }
2417
+ }
2418
+
2419
+ function loadTagKeys() {
2420
+ fetch('/api/resources/tags/keys')
2421
+ .then(r => r.json())
2422
+ .then(result => {
2423
+ const data = Alpine.$data(document.querySelector('[x-data]'));
2424
+ data.availableTagKeys = result.keys || [];
2425
+ data.initTagColumns();
2426
+ });
2427
+ }
2428
+ </script>
2429
+ {% endblock %}