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