aws-inventory-manager 0.13.2__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.

Potentially problematic release.


This version of aws-inventory-manager might be problematic. Click here for more details.

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