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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (145) hide show
  1. aws_inventory_manager-0.13.2.dist-info/LICENSE +21 -0
  2. aws_inventory_manager-0.13.2.dist-info/METADATA +1226 -0
  3. aws_inventory_manager-0.13.2.dist-info/RECORD +145 -0
  4. aws_inventory_manager-0.13.2.dist-info/WHEEL +5 -0
  5. aws_inventory_manager-0.13.2.dist-info/entry_points.txt +2 -0
  6. aws_inventory_manager-0.13.2.dist-info/top_level.txt +1 -0
  7. src/__init__.py +3 -0
  8. src/aws/__init__.py +11 -0
  9. src/aws/client.py +128 -0
  10. src/aws/credentials.py +191 -0
  11. src/aws/rate_limiter.py +177 -0
  12. src/cli/__init__.py +12 -0
  13. src/cli/config.py +130 -0
  14. src/cli/main.py +3626 -0
  15. src/config_service/__init__.py +21 -0
  16. src/config_service/collector.py +346 -0
  17. src/config_service/detector.py +256 -0
  18. src/config_service/resource_type_mapping.py +328 -0
  19. src/cost/__init__.py +5 -0
  20. src/cost/analyzer.py +226 -0
  21. src/cost/explorer.py +209 -0
  22. src/cost/reporter.py +237 -0
  23. src/delta/__init__.py +5 -0
  24. src/delta/calculator.py +206 -0
  25. src/delta/differ.py +185 -0
  26. src/delta/formatters.py +272 -0
  27. src/delta/models.py +154 -0
  28. src/delta/reporter.py +234 -0
  29. src/models/__init__.py +21 -0
  30. src/models/config_diff.py +135 -0
  31. src/models/cost_report.py +87 -0
  32. src/models/deletion_operation.py +104 -0
  33. src/models/deletion_record.py +97 -0
  34. src/models/delta_report.py +122 -0
  35. src/models/efs_resource.py +80 -0
  36. src/models/elasticache_resource.py +90 -0
  37. src/models/group.py +318 -0
  38. src/models/inventory.py +133 -0
  39. src/models/protection_rule.py +123 -0
  40. src/models/report.py +288 -0
  41. src/models/resource.py +111 -0
  42. src/models/security_finding.py +102 -0
  43. src/models/snapshot.py +122 -0
  44. src/restore/__init__.py +20 -0
  45. src/restore/audit.py +175 -0
  46. src/restore/cleaner.py +461 -0
  47. src/restore/config.py +209 -0
  48. src/restore/deleter.py +976 -0
  49. src/restore/dependency.py +254 -0
  50. src/restore/safety.py +115 -0
  51. src/security/__init__.py +0 -0
  52. src/security/checks/__init__.py +0 -0
  53. src/security/checks/base.py +56 -0
  54. src/security/checks/ec2_checks.py +88 -0
  55. src/security/checks/elasticache_checks.py +149 -0
  56. src/security/checks/iam_checks.py +102 -0
  57. src/security/checks/rds_checks.py +140 -0
  58. src/security/checks/s3_checks.py +95 -0
  59. src/security/checks/secrets_checks.py +96 -0
  60. src/security/checks/sg_checks.py +142 -0
  61. src/security/cis_mapper.py +97 -0
  62. src/security/models.py +53 -0
  63. src/security/reporter.py +174 -0
  64. src/security/scanner.py +87 -0
  65. src/snapshot/__init__.py +6 -0
  66. src/snapshot/capturer.py +451 -0
  67. src/snapshot/filter.py +259 -0
  68. src/snapshot/inventory_storage.py +236 -0
  69. src/snapshot/report_formatter.py +250 -0
  70. src/snapshot/reporter.py +189 -0
  71. src/snapshot/resource_collectors/__init__.py +5 -0
  72. src/snapshot/resource_collectors/apigateway.py +140 -0
  73. src/snapshot/resource_collectors/backup.py +136 -0
  74. src/snapshot/resource_collectors/base.py +81 -0
  75. src/snapshot/resource_collectors/cloudformation.py +55 -0
  76. src/snapshot/resource_collectors/cloudwatch.py +109 -0
  77. src/snapshot/resource_collectors/codebuild.py +69 -0
  78. src/snapshot/resource_collectors/codepipeline.py +82 -0
  79. src/snapshot/resource_collectors/dynamodb.py +65 -0
  80. src/snapshot/resource_collectors/ec2.py +240 -0
  81. src/snapshot/resource_collectors/ecs.py +215 -0
  82. src/snapshot/resource_collectors/efs_collector.py +102 -0
  83. src/snapshot/resource_collectors/eks.py +200 -0
  84. src/snapshot/resource_collectors/elasticache_collector.py +79 -0
  85. src/snapshot/resource_collectors/elb.py +126 -0
  86. src/snapshot/resource_collectors/eventbridge.py +156 -0
  87. src/snapshot/resource_collectors/iam.py +188 -0
  88. src/snapshot/resource_collectors/kms.py +111 -0
  89. src/snapshot/resource_collectors/lambda_func.py +139 -0
  90. src/snapshot/resource_collectors/rds.py +109 -0
  91. src/snapshot/resource_collectors/route53.py +86 -0
  92. src/snapshot/resource_collectors/s3.py +105 -0
  93. src/snapshot/resource_collectors/secretsmanager.py +70 -0
  94. src/snapshot/resource_collectors/sns.py +68 -0
  95. src/snapshot/resource_collectors/sqs.py +82 -0
  96. src/snapshot/resource_collectors/ssm.py +160 -0
  97. src/snapshot/resource_collectors/stepfunctions.py +74 -0
  98. src/snapshot/resource_collectors/vpcendpoints.py +79 -0
  99. src/snapshot/resource_collectors/waf.py +159 -0
  100. src/snapshot/storage.py +351 -0
  101. src/storage/__init__.py +21 -0
  102. src/storage/audit_store.py +419 -0
  103. src/storage/database.py +294 -0
  104. src/storage/group_store.py +749 -0
  105. src/storage/inventory_store.py +320 -0
  106. src/storage/resource_store.py +413 -0
  107. src/storage/schema.py +288 -0
  108. src/storage/snapshot_store.py +346 -0
  109. src/utils/__init__.py +12 -0
  110. src/utils/export.py +305 -0
  111. src/utils/hash.py +60 -0
  112. src/utils/logging.py +63 -0
  113. src/utils/pagination.py +41 -0
  114. src/utils/paths.py +51 -0
  115. src/utils/progress.py +41 -0
  116. src/utils/unsupported_resources.py +306 -0
  117. src/web/__init__.py +5 -0
  118. src/web/app.py +97 -0
  119. src/web/dependencies.py +69 -0
  120. src/web/routes/__init__.py +1 -0
  121. src/web/routes/api/__init__.py +18 -0
  122. src/web/routes/api/charts.py +156 -0
  123. src/web/routes/api/cleanup.py +186 -0
  124. src/web/routes/api/filters.py +253 -0
  125. src/web/routes/api/groups.py +305 -0
  126. src/web/routes/api/inventories.py +80 -0
  127. src/web/routes/api/queries.py +202 -0
  128. src/web/routes/api/resources.py +379 -0
  129. src/web/routes/api/snapshots.py +314 -0
  130. src/web/routes/api/views.py +260 -0
  131. src/web/routes/pages.py +198 -0
  132. src/web/services/__init__.py +1 -0
  133. src/web/templates/base.html +949 -0
  134. src/web/templates/components/navbar.html +31 -0
  135. src/web/templates/components/sidebar.html +104 -0
  136. src/web/templates/pages/audit_logs.html +86 -0
  137. src/web/templates/pages/cleanup.html +279 -0
  138. src/web/templates/pages/dashboard.html +227 -0
  139. src/web/templates/pages/diff.html +175 -0
  140. src/web/templates/pages/error.html +30 -0
  141. src/web/templates/pages/groups.html +721 -0
  142. src/web/templates/pages/queries.html +246 -0
  143. src/web/templates/pages/resources.html +2251 -0
  144. src/web/templates/pages/snapshot_detail.html +271 -0
  145. src/web/templates/pages/snapshots.html +429 -0
@@ -0,0 +1,721 @@
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Resource Groups - AWS Inventory Browser{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="space-y-6" x-data="groupsPage()">
7
+ <!-- Header -->
8
+ <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
9
+ <div>
10
+ <div class="flex items-center space-x-3">
11
+ <div class="w-12 h-12 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center shadow-lg shadow-indigo-200">
12
+ <svg class="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
13
+ <path stroke-linecap="round" stroke-linejoin="round" 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 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
14
+ </svg>
15
+ </div>
16
+ <div>
17
+ <h1 class="text-2xl font-bold text-gray-900">Resource Groups</h1>
18
+ <p class="text-sm text-gray-500">Define baseline resource sets for cross-account comparison</p>
19
+ </div>
20
+ </div>
21
+ </div>
22
+ <button type="button" @click="showCreateModal = true" class="btn btn-primary">
23
+ <svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
24
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
25
+ </svg>
26
+ Create Group
27
+ </button>
28
+ </div>
29
+
30
+ <!-- Groups Grid -->
31
+ <div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3" x-show="groups.length > 0">
32
+ <template x-for="group in groups" :key="group.name">
33
+ <div class="group relative bg-white rounded-2xl shadow-md hover:shadow-xl transition-all duration-300 overflow-hidden border border-gray-100">
34
+ <!-- Gradient top border -->
35
+ <div class="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500"></div>
36
+
37
+ <div class="p-6">
38
+ <!-- Header with name and favorite -->
39
+ <div class="flex items-start justify-between">
40
+ <div class="flex items-center space-x-3">
41
+ <div class="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-100 to-purple-100 flex items-center justify-center">
42
+ <svg class="w-5 h-5 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
43
+ <path stroke-linecap="round" stroke-linejoin="round" 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"/>
44
+ </svg>
45
+ </div>
46
+ <h3 class="text-lg font-bold text-gray-900 truncate" x-text="group.name"></h3>
47
+ </div>
48
+ <button type="button" @click="toggleFavorite(group.name)"
49
+ class="p-2 rounded-lg transition-all duration-200"
50
+ :class="group.is_favorite ? 'text-amber-500 bg-amber-50' : 'text-gray-300 hover:text-amber-400 hover:bg-amber-50'">
51
+ <svg class="h-5 w-5" :fill="group.is_favorite ? 'currentColor' : 'none'" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
52
+ <path stroke-linecap="round" stroke-linejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/>
53
+ </svg>
54
+ </button>
55
+ </div>
56
+
57
+ <!-- Description -->
58
+ <p class="mt-3 text-sm text-gray-500 line-clamp-2" x-text="group.description || 'No description provided'"></p>
59
+
60
+ <!-- Stats -->
61
+ <div class="mt-5 flex flex-wrap gap-3">
62
+ <div class="flex items-center px-3 py-1.5 rounded-full bg-gradient-to-r from-indigo-50 to-purple-50 border border-indigo-100">
63
+ <svg class="w-4 h-4 text-indigo-600 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
64
+ <path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
65
+ </svg>
66
+ <span class="text-sm font-semibold text-indigo-700" x-text="group.resource_count.toLocaleString()"></span>
67
+ <span class="text-sm text-indigo-600 ml-1">resources</span>
68
+ </div>
69
+ <div x-show="group.source_snapshot" class="flex items-center px-3 py-1.5 rounded-full bg-gray-50 border border-gray-200">
70
+ <svg class="w-4 h-4 text-gray-500 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
71
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
72
+ </svg>
73
+ <span class="text-xs font-medium text-gray-600 truncate max-w-[120px]" x-text="group.source_snapshot"></span>
74
+ </div>
75
+ </div>
76
+
77
+ <!-- Actions -->
78
+ <div class="mt-5 pt-5 border-t border-gray-100 flex gap-2">
79
+ <button type="button" @click="viewMembers(group.name)"
80
+ class="flex-1 px-4 py-2.5 text-sm font-semibold text-gray-700 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors">
81
+ <svg class="w-4 h-4 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
82
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
83
+ <path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
84
+ </svg>
85
+ View
86
+ </button>
87
+ <button type="button" @click="showCompareModal = true; compareGroupName = group.name"
88
+ class="flex-1 px-4 py-2.5 text-sm font-semibold text-white bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 rounded-xl shadow-md shadow-indigo-200 transition-all">
89
+ <svg class="w-4 h-4 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
90
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
91
+ </svg>
92
+ Compare
93
+ </button>
94
+ <button type="button" @click="confirmDelete(group.name)"
95
+ class="p-2.5 text-gray-400 hover:text-rose-600 hover:bg-rose-50 rounded-xl transition-colors">
96
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
97
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
98
+ </svg>
99
+ </button>
100
+ </div>
101
+ </div>
102
+ </div>
103
+ </template>
104
+ </div>
105
+
106
+ <!-- Empty State -->
107
+ <div x-show="groups.length === 0" class="text-center py-16 bg-white rounded-2xl shadow-lg border border-gray-100">
108
+ <div class="w-20 h-20 mx-auto rounded-2xl bg-gradient-to-br from-indigo-100 to-purple-100 flex items-center justify-center mb-6">
109
+ <svg class="w-10 h-10 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
110
+ <path stroke-linecap="round" stroke-linejoin="round" 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 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
111
+ </svg>
112
+ </div>
113
+ <h3 class="text-xl font-bold text-gray-900">No resource groups yet</h3>
114
+ <p class="mt-2 text-gray-500 max-w-sm mx-auto">Create a group to define baseline resources for cross-account comparison.</p>
115
+ <div class="mt-6">
116
+ <button type="button" @click="showCreateModal = true" class="btn btn-primary">
117
+ <svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
118
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
119
+ </svg>
120
+ Create Your First Group
121
+ </button>
122
+ </div>
123
+ </div>
124
+
125
+ <!-- Create Group Modal -->
126
+ <div x-show="showCreateModal" class="fixed z-50 inset-0 overflow-y-auto" x-cloak>
127
+ <div class="flex items-center justify-center min-h-screen px-4">
128
+ <div class="fixed inset-0 bg-gray-900/60 backdrop-blur-sm transition-opacity" @click="showCreateModal = false"></div>
129
+
130
+ <div class="relative bg-white rounded-2xl shadow-2xl max-w-lg w-full overflow-hidden"
131
+ x-transition:enter="transition ease-out duration-300"
132
+ x-transition:enter-start="opacity-0 scale-95"
133
+ x-transition:enter-end="opacity-100 scale-100">
134
+ <!-- Modal Header -->
135
+ <div class="bg-gradient-to-r from-indigo-500 to-purple-600 px-6 py-5">
136
+ <h3 class="text-xl font-bold text-white">Create Resource Group</h3>
137
+ <p class="text-indigo-100 text-sm mt-1">Define a new baseline for comparison</p>
138
+ </div>
139
+
140
+ <form @submit.prevent="createGroup" class="p-6 space-y-5">
141
+ <div>
142
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Group Name</label>
143
+ <input type="text" x-model="newGroup.name" required
144
+ class="w-full" placeholder="e.g., production-baseline">
145
+ </div>
146
+ <div>
147
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Description</label>
148
+ <textarea x-model="newGroup.description" rows="2"
149
+ class="w-full" placeholder="Optional description of this group"></textarea>
150
+ </div>
151
+ <div>
152
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Populate from Snapshot</label>
153
+ <select x-model="newGroup.from_snapshot" class="w-full">
154
+ <option value="">-- Create empty group --</option>
155
+ {% for snapshot in snapshots %}
156
+ <option value="{{ snapshot.name }}">{{ snapshot.name }} ({{ snapshot.resource_count }} resources)</option>
157
+ {% endfor %}
158
+ </select>
159
+ </div>
160
+ <div x-show="newGroup.from_snapshot" x-transition class="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-xl">
161
+ <div>
162
+ <label class="block text-xs font-semibold text-gray-600 mb-1.5">Type Filter</label>
163
+ <input type="text" x-model="newGroup.type_filter"
164
+ class="w-full text-sm" placeholder="e.g., iam">
165
+ </div>
166
+ <div>
167
+ <label class="block text-xs font-semibold text-gray-600 mb-1.5">Region Filter</label>
168
+ <input type="text" x-model="newGroup.region_filter"
169
+ class="w-full text-sm" placeholder="e.g., us-east-1">
170
+ </div>
171
+ </div>
172
+ <div class="flex gap-3 pt-4">
173
+ <button type="button" @click="showCreateModal = false"
174
+ class="flex-1 px-5 py-3 text-sm font-semibold text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-xl transition-colors">
175
+ Cancel
176
+ </button>
177
+ <button type="submit" class="flex-1 btn btn-primary" :disabled="creating">
178
+ <span x-show="!creating">Create Group</span>
179
+ <span x-show="creating" class="flex items-center justify-center">
180
+ <svg class="animate-spin h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24">
181
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
182
+ <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>
183
+ </svg>
184
+ Creating...
185
+ </span>
186
+ </button>
187
+ </div>
188
+ </form>
189
+ </div>
190
+ </div>
191
+ </div>
192
+
193
+ <!-- Compare Modal -->
194
+ <div x-show="showCompareModal" class="fixed z-50 inset-0 overflow-y-auto" x-cloak>
195
+ <div class="flex items-center justify-center min-h-screen px-4">
196
+ <div class="fixed inset-0 bg-gray-900/60 backdrop-blur-sm transition-opacity" @click="showCompareModal = false"></div>
197
+
198
+ <div class="relative bg-white rounded-2xl shadow-2xl max-w-2xl w-full overflow-hidden"
199
+ x-transition:enter="transition ease-out duration-300"
200
+ x-transition:enter-start="opacity-0 scale-95"
201
+ x-transition:enter-end="opacity-100 scale-100">
202
+ <!-- Modal Header -->
203
+ <div class="bg-gradient-to-r from-indigo-500 to-purple-600 px-6 py-5">
204
+ <h3 class="text-xl font-bold text-white">Compare Snapshot to Group</h3>
205
+ <p class="text-indigo-100 text-sm mt-1">Comparing against "<span x-text="compareGroupName" class="font-semibold"></span>"</p>
206
+ </div>
207
+
208
+ <div class="p-6">
209
+ <div>
210
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Select Snapshot</label>
211
+ <select x-model="compareSnapshot" class="w-full">
212
+ <option value="">-- Choose a snapshot to compare --</option>
213
+ {% for snapshot in snapshots %}
214
+ <option value="{{ snapshot.name }}">{{ snapshot.name }}</option>
215
+ {% endfor %}
216
+ </select>
217
+ </div>
218
+
219
+ <!-- Results -->
220
+ <div x-show="compareResult" x-transition class="mt-6">
221
+ <div class="grid grid-cols-3 gap-4">
222
+ <div class="bg-gradient-to-br from-emerald-50 to-green-50 rounded-xl p-4 border border-emerald-100">
223
+ <div class="text-3xl font-bold text-emerald-600" x-text="compareResult?.matched || 0"></div>
224
+ <div class="text-sm font-medium text-emerald-700 mt-1">Matched</div>
225
+ <div class="text-xs text-emerald-500">In both group & snapshot</div>
226
+ </div>
227
+ <div class="bg-gradient-to-br from-rose-50 to-red-50 rounded-xl p-4 border border-rose-100">
228
+ <div class="text-3xl font-bold text-rose-600" x-text="compareResult?.missing_from_snapshot || 0"></div>
229
+ <div class="text-sm font-medium text-rose-700 mt-1">Missing</div>
230
+ <div class="text-xs text-rose-500">Not in snapshot</div>
231
+ </div>
232
+ <div class="bg-gradient-to-br from-amber-50 to-yellow-50 rounded-xl p-4 border border-amber-100">
233
+ <div class="text-3xl font-bold text-amber-600" x-text="compareResult?.not_in_group || 0"></div>
234
+ <div class="text-sm font-medium text-amber-700 mt-1">Extra</div>
235
+ <div class="text-xs text-amber-500">Not in group</div>
236
+ </div>
237
+ </div>
238
+
239
+ <!-- Missing resources -->
240
+ <div x-show="compareResult?.resources?.missing?.length > 0" class="mt-5">
241
+ <h4 class="text-sm font-bold text-rose-700 flex items-center">
242
+ <svg class="w-4 h-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
243
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
244
+ </svg>
245
+ Missing from Snapshot
246
+ </h4>
247
+ <div class="mt-2 max-h-32 overflow-y-auto space-y-1">
248
+ <template x-for="r in (compareResult?.resources?.missing || []).slice(0, 20)" :key="r.name + r.resource_type">
249
+ <div class="flex items-center justify-between text-sm py-2 px-3 bg-rose-50 rounded-lg border border-rose-100">
250
+ <span class="font-medium text-gray-900" x-text="r.name"></span>
251
+ <span class="text-xs font-medium text-rose-600 px-2 py-0.5 bg-rose-100 rounded-full" x-text="r.resource_type"></span>
252
+ </div>
253
+ </template>
254
+ </div>
255
+ <p x-show="compareResult?.resources?.missing?.length > 20" class="text-xs text-gray-500 mt-2 text-center">
256
+ ...and <span x-text="(compareResult?.resources?.missing?.length || 0) - 20" class="font-semibold"></span> more
257
+ </p>
258
+ </div>
259
+
260
+ <!-- Extra resources -->
261
+ <div x-show="compareResult?.resources?.extra?.length > 0" class="mt-5">
262
+ <div class="flex items-center justify-between mb-2">
263
+ <h4 class="text-sm font-bold text-amber-700 flex items-center">
264
+ <svg class="w-4 h-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
265
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
266
+ </svg>
267
+ Extra Resources (Not in Group)
268
+ </h4>
269
+ <!-- Action buttons for extra resources -->
270
+ <div class="flex gap-2">
271
+ <button type="button" @click="showAddToGroupMenu = !showAddToGroupMenu"
272
+ class="text-xs px-3 py-1.5 bg-indigo-100 hover:bg-indigo-200 text-indigo-700 rounded-lg font-semibold transition-colors flex items-center">
273
+ <svg class="w-3.5 h-3.5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
274
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
275
+ </svg>
276
+ Add to Group
277
+ </button>
278
+ <button type="button" @click="openCreateFromExtra()"
279
+ class="text-xs px-3 py-1.5 bg-emerald-100 hover:bg-emerald-200 text-emerald-700 rounded-lg font-semibold transition-colors flex items-center">
280
+ <svg class="w-3.5 h-3.5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
281
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
282
+ </svg>
283
+ Create New Group
284
+ </button>
285
+ </div>
286
+ </div>
287
+
288
+ <!-- Add to group dropdown menu -->
289
+ <div x-show="showAddToGroupMenu" x-transition
290
+ @click.away="showAddToGroupMenu = false"
291
+ class="mb-3 p-3 bg-white border border-gray-200 rounded-xl shadow-lg">
292
+ <p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Select target group</p>
293
+ <div class="space-y-1 max-h-32 overflow-y-auto">
294
+ <template x-for="g in groups.filter(g => g.name !== compareGroupName)" :key="g.name">
295
+ <button type="button" @click="addExtraToGroup(g.name)"
296
+ class="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-indigo-50 transition-colors flex items-center justify-between"
297
+ :disabled="addingToGroup">
298
+ <span class="font-medium text-gray-900" x-text="g.name"></span>
299
+ <span class="text-xs text-gray-500" x-text="g.resource_count + ' resources'"></span>
300
+ </button>
301
+ </template>
302
+ </div>
303
+ <div x-show="groups.filter(g => g.name !== compareGroupName).length === 0" class="text-sm text-gray-500 text-center py-2">
304
+ No other groups available
305
+ </div>
306
+ </div>
307
+
308
+ <div class="mt-2 max-h-32 overflow-y-auto space-y-1">
309
+ <template x-for="r in (compareResult?.resources?.extra || []).slice(0, 20)" :key="r.arn">
310
+ <div class="flex items-center justify-between text-sm py-2 px-3 bg-amber-50 rounded-lg border border-amber-100">
311
+ <span class="font-medium text-gray-900" x-text="r.name"></span>
312
+ <span class="text-xs font-medium text-amber-600 px-2 py-0.5 bg-amber-100 rounded-full" x-text="r.resource_type"></span>
313
+ </div>
314
+ </template>
315
+ </div>
316
+ <p x-show="compareResult?.resources?.extra?.length > 20" class="text-xs text-gray-500 mt-2 text-center">
317
+ ...and <span x-text="(compareResult?.resources?.extra?.length || 0) - 20" class="font-semibold"></span> more
318
+ </p>
319
+ </div>
320
+ </div>
321
+
322
+ <!-- Add to current group button (when extra resources exist) -->
323
+ <div x-show="compareResult?.resources?.extra?.length > 0" class="pt-4 border-t border-gray-100">
324
+ <button type="button" @click="addExtraToCurrentGroup()"
325
+ class="w-full px-4 py-3 text-sm font-semibold text-indigo-700 bg-indigo-50 hover:bg-indigo-100 rounded-xl transition-colors flex items-center justify-center"
326
+ :disabled="addingToGroup">
327
+ <svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
328
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
329
+ </svg>
330
+ <span x-show="!addingToGroup">Add All Extra to "<span x-text="compareGroupName"></span>"</span>
331
+ <span x-show="addingToGroup">Adding...</span>
332
+ </button>
333
+ </div>
334
+
335
+ <div class="flex gap-3 pt-6">
336
+ <button type="button" @click="showCompareModal = false; compareResult = null"
337
+ class="flex-1 px-5 py-3 text-sm font-semibold text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-xl transition-colors">
338
+ Close
339
+ </button>
340
+ <button type="button" @click="runComparison()" class="flex-1 btn btn-primary"
341
+ :disabled="!compareSnapshot || comparing">
342
+ <span x-show="!comparing">Run Comparison</span>
343
+ <span x-show="comparing" class="flex items-center justify-center">
344
+ <svg class="animate-spin h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24">
345
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
346
+ <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>
347
+ </svg>
348
+ Comparing...
349
+ </span>
350
+ </button>
351
+ </div>
352
+ </div>
353
+ </div>
354
+ </div>
355
+ </div>
356
+
357
+ <!-- Create Group from Extra Modal -->
358
+ <div x-show="showCreateFromExtraModal" class="fixed z-[60] inset-0 overflow-y-auto" x-cloak>
359
+ <div class="flex items-center justify-center min-h-screen px-4">
360
+ <div class="fixed inset-0 bg-gray-900/60 backdrop-blur-sm transition-opacity" @click="showCreateFromExtraModal = false"></div>
361
+
362
+ <div class="relative bg-white rounded-2xl shadow-2xl max-w-md w-full overflow-hidden"
363
+ x-transition:enter="transition ease-out duration-300"
364
+ x-transition:enter-start="opacity-0 scale-95"
365
+ x-transition:enter-end="opacity-100 scale-100">
366
+ <!-- Modal Header -->
367
+ <div class="bg-gradient-to-r from-emerald-500 to-teal-600 px-6 py-5">
368
+ <h3 class="text-xl font-bold text-white">Create Group from Extra Resources</h3>
369
+ <p class="text-emerald-100 text-sm mt-1">
370
+ <span x-text="compareResult?.resources?.extra?.length || 0"></span> resources will be added
371
+ </p>
372
+ </div>
373
+
374
+ <form @submit.prevent="createGroupFromExtra" class="p-6 space-y-5">
375
+ <div>
376
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Group Name</label>
377
+ <input type="text" x-model="newGroupFromExtra.name" required
378
+ class="w-full" placeholder="e.g., account-specific-resources">
379
+ </div>
380
+ <div>
381
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Description</label>
382
+ <textarea x-model="newGroupFromExtra.description" rows="2"
383
+ class="w-full" placeholder="Optional description"></textarea>
384
+ </div>
385
+ <div class="flex gap-3 pt-4">
386
+ <button type="button" @click="showCreateFromExtraModal = false"
387
+ class="flex-1 px-5 py-3 text-sm font-semibold text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-xl transition-colors">
388
+ Cancel
389
+ </button>
390
+ <button type="submit" class="flex-1 btn btn-primary bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700" :disabled="creating">
391
+ <span x-show="!creating">Create Group</span>
392
+ <span x-show="creating" class="flex items-center justify-center">
393
+ <svg class="animate-spin h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24">
394
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
395
+ <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>
396
+ </svg>
397
+ Creating...
398
+ </span>
399
+ </button>
400
+ </div>
401
+ </form>
402
+ </div>
403
+ </div>
404
+ </div>
405
+
406
+ <!-- View Members Modal -->
407
+ <div x-show="showMembersModal" class="fixed z-50 inset-0 overflow-y-auto" x-cloak>
408
+ <div class="flex items-center justify-center min-h-screen px-4">
409
+ <div class="fixed inset-0 bg-gray-900/60 backdrop-blur-sm transition-opacity" @click="showMembersModal = false"></div>
410
+
411
+ <div class="relative bg-white rounded-2xl shadow-2xl max-w-4xl w-full overflow-hidden"
412
+ x-transition:enter="transition ease-out duration-300"
413
+ x-transition:enter-start="opacity-0 scale-95"
414
+ x-transition:enter-end="opacity-100 scale-100">
415
+ <!-- Modal Header -->
416
+ <div class="bg-gradient-to-r from-indigo-500 to-purple-600 px-6 py-5">
417
+ <h3 class="text-xl font-bold text-white">Group Members</h3>
418
+ <p class="text-indigo-100 text-sm mt-1">
419
+ <span x-text="membersGroupName" class="font-semibold"></span>
420
+ <span class="mx-2">•</span>
421
+ <span x-text="members.length"></span> resources
422
+ </p>
423
+ </div>
424
+
425
+ <div class="p-6">
426
+ <div class="max-h-[60vh] overflow-y-auto rounded-xl border border-gray-200">
427
+ <table class="min-w-full divide-y divide-gray-200">
428
+ <thead class="bg-gray-50 sticky top-0">
429
+ <tr>
430
+ <th class="px-5 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Name</th>
431
+ <th class="px-5 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Type</th>
432
+ <th class="px-5 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Original ARN</th>
433
+ </tr>
434
+ </thead>
435
+ <tbody class="bg-white divide-y divide-gray-100">
436
+ <template x-for="member in members" :key="member.resource_name + member.resource_type">
437
+ <tr class="hover:bg-indigo-50/50 transition-colors">
438
+ <td class="px-5 py-3 text-sm font-semibold text-gray-900" x-text="member.resource_name"></td>
439
+ <td class="px-5 py-3">
440
+ <span class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-semibold bg-gradient-to-r from-indigo-50 to-purple-50 text-indigo-700 border border-indigo-100" x-text="member.resource_type"></span>
441
+ </td>
442
+ <td class="px-5 py-3 text-xs text-gray-400 font-mono truncate max-w-xs" x-text="member.original_arn || '-'"></td>
443
+ </tr>
444
+ </template>
445
+ </tbody>
446
+ </table>
447
+ </div>
448
+
449
+ <div class="mt-6">
450
+ <button type="button" @click="showMembersModal = false"
451
+ class="w-full px-5 py-3 text-sm font-semibold text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-xl transition-colors">
452
+ Close
453
+ </button>
454
+ </div>
455
+ </div>
456
+ </div>
457
+ </div>
458
+ </div>
459
+ </div>
460
+ {% endblock %}
461
+
462
+ {% block scripts %}
463
+ <script>
464
+ function groupsPage() {
465
+ return {
466
+ groups: {{ groups | tojson }},
467
+ showCreateModal: false,
468
+ showCompareModal: false,
469
+ showMembersModal: false,
470
+ showCreateFromExtraModal: false,
471
+ showAddToGroupMenu: false,
472
+ creating: false,
473
+ comparing: false,
474
+ addingToGroup: false,
475
+ newGroup: {
476
+ name: '',
477
+ description: '',
478
+ from_snapshot: '',
479
+ type_filter: '',
480
+ region_filter: ''
481
+ },
482
+ newGroupFromExtra: {
483
+ name: '',
484
+ description: ''
485
+ },
486
+ compareGroupName: '',
487
+ compareSnapshot: '',
488
+ compareResult: null,
489
+ membersGroupName: '',
490
+ members: [],
491
+
492
+ async createGroup() {
493
+ if (!this.newGroup.name) return;
494
+
495
+ this.creating = true;
496
+ try {
497
+ const response = await fetch('/api/groups', {
498
+ method: 'POST',
499
+ headers: { 'Content-Type': 'application/json' },
500
+ body: JSON.stringify(this.newGroup)
501
+ });
502
+
503
+ if (!response.ok) {
504
+ const error = await response.json();
505
+ throw new Error(error.detail || 'Failed to create group');
506
+ }
507
+
508
+ // Reload to refresh groups
509
+ window.location.reload();
510
+ } catch (error) {
511
+ alert('Error: ' + error.message);
512
+ } finally {
513
+ this.creating = false;
514
+ }
515
+ },
516
+
517
+ async toggleFavorite(name) {
518
+ try {
519
+ const response = await fetch('/api/groups/' + encodeURIComponent(name) + '/favorite', {
520
+ method: 'POST'
521
+ });
522
+
523
+ if (response.ok) {
524
+ const result = await response.json();
525
+ const group = this.groups.find(g => g.name === name);
526
+ if (group) group.is_favorite = result.is_favorite;
527
+ }
528
+ } catch (error) {
529
+ console.error('Failed to toggle favorite:', error);
530
+ }
531
+ },
532
+
533
+ async confirmDelete(name) {
534
+ if (!confirm('Are you sure you want to delete group "' + name + '"?')) return;
535
+
536
+ try {
537
+ const response = await fetch('/api/groups/' + encodeURIComponent(name), {
538
+ method: 'DELETE'
539
+ });
540
+
541
+ if (response.ok) {
542
+ this.groups = this.groups.filter(g => g.name !== name);
543
+ } else {
544
+ const error = await response.json();
545
+ throw new Error(error.detail || 'Failed to delete group');
546
+ }
547
+ } catch (error) {
548
+ alert('Error: ' + error.message);
549
+ }
550
+ },
551
+
552
+ async viewMembers(name) {
553
+ this.membersGroupName = name;
554
+ this.members = [];
555
+ this.showMembersModal = true;
556
+
557
+ try {
558
+ const response = await fetch('/api/groups/' + encodeURIComponent(name) + '/members?limit=500');
559
+ if (response.ok) {
560
+ const result = await response.json();
561
+ this.members = result.members;
562
+ }
563
+ } catch (error) {
564
+ console.error('Failed to load members:', error);
565
+ }
566
+ },
567
+
568
+ async runComparison() {
569
+ if (!this.compareSnapshot || !this.compareGroupName) return;
570
+
571
+ this.comparing = true;
572
+ this.compareResult = null;
573
+
574
+ try {
575
+ const response = await fetch(
576
+ '/api/groups/' + encodeURIComponent(this.compareGroupName) + '/compare/' + encodeURIComponent(this.compareSnapshot)
577
+ );
578
+
579
+ if (response.ok) {
580
+ this.compareResult = await response.json();
581
+ } else {
582
+ const error = await response.json();
583
+ throw new Error(error.detail || 'Comparison failed');
584
+ }
585
+ } catch (error) {
586
+ alert('Error: ' + error.message);
587
+ } finally {
588
+ this.comparing = false;
589
+ }
590
+ },
591
+
592
+ openCreateFromExtra() {
593
+ this.newGroupFromExtra = {
594
+ name: '',
595
+ description: 'Resources not in ' + this.compareGroupName + ' (from ' + this.compareSnapshot + ')'
596
+ };
597
+ this.showCreateFromExtraModal = true;
598
+ },
599
+
600
+ async createGroupFromExtra() {
601
+ if (!this.newGroupFromExtra.name || !this.compareResult?.resources?.extra?.length) return;
602
+
603
+ this.creating = true;
604
+ try {
605
+ // First create an empty group
606
+ const createResponse = await fetch('/api/groups', {
607
+ method: 'POST',
608
+ headers: { 'Content-Type': 'application/json' },
609
+ body: JSON.stringify({
610
+ name: this.newGroupFromExtra.name,
611
+ description: this.newGroupFromExtra.description
612
+ })
613
+ });
614
+
615
+ if (!createResponse.ok) {
616
+ const error = await createResponse.json();
617
+ throw new Error(error.detail || 'Failed to create group');
618
+ }
619
+
620
+ // Then add the extra resources to it
621
+ const arns = this.compareResult.resources.extra.map(r => ({
622
+ arn: r.arn,
623
+ resource_type: r.resource_type
624
+ }));
625
+
626
+ const addResponse = await fetch('/api/groups/' + encodeURIComponent(this.newGroupFromExtra.name) + '/members', {
627
+ method: 'POST',
628
+ headers: { 'Content-Type': 'application/json' },
629
+ body: JSON.stringify({ arns: arns })
630
+ });
631
+
632
+ if (!addResponse.ok) {
633
+ const error = await addResponse.json();
634
+ throw new Error(error.detail || 'Failed to add resources');
635
+ }
636
+
637
+ const result = await addResponse.json();
638
+ alert('Created group "' + this.newGroupFromExtra.name + '" with ' + result.added + ' resources');
639
+ window.location.reload();
640
+ } catch (error) {
641
+ alert('Error: ' + error.message);
642
+ } finally {
643
+ this.creating = false;
644
+ }
645
+ },
646
+
647
+ async addExtraToGroup(targetGroupName) {
648
+ if (!this.compareResult?.resources?.extra?.length) return;
649
+
650
+ this.addingToGroup = true;
651
+ this.showAddToGroupMenu = false;
652
+
653
+ try {
654
+ const arns = this.compareResult.resources.extra.map(r => ({
655
+ arn: r.arn,
656
+ resource_type: r.resource_type
657
+ }));
658
+
659
+ const response = await fetch('/api/groups/' + encodeURIComponent(targetGroupName) + '/members', {
660
+ method: 'POST',
661
+ headers: { 'Content-Type': 'application/json' },
662
+ body: JSON.stringify({ arns: arns })
663
+ });
664
+
665
+ if (!response.ok) {
666
+ const error = await response.json();
667
+ throw new Error(error.detail || 'Failed to add resources');
668
+ }
669
+
670
+ const result = await response.json();
671
+ alert('Added ' + result.added + ' resources to group "' + targetGroupName + '"');
672
+
673
+ // Update local group count
674
+ const group = this.groups.find(g => g.name === targetGroupName);
675
+ if (group) {
676
+ group.resource_count += result.added;
677
+ }
678
+ } catch (error) {
679
+ alert('Error: ' + error.message);
680
+ } finally {
681
+ this.addingToGroup = false;
682
+ }
683
+ },
684
+
685
+ async addExtraToCurrentGroup() {
686
+ if (!this.compareResult?.resources?.extra?.length || !this.compareGroupName) return;
687
+
688
+ this.addingToGroup = true;
689
+
690
+ try {
691
+ const arns = this.compareResult.resources.extra.map(r => ({
692
+ arn: r.arn,
693
+ resource_type: r.resource_type
694
+ }));
695
+
696
+ const response = await fetch('/api/groups/' + encodeURIComponent(this.compareGroupName) + '/members', {
697
+ method: 'POST',
698
+ headers: { 'Content-Type': 'application/json' },
699
+ body: JSON.stringify({ arns: arns })
700
+ });
701
+
702
+ if (!response.ok) {
703
+ const error = await response.json();
704
+ throw new Error(error.detail || 'Failed to add resources');
705
+ }
706
+
707
+ const result = await response.json();
708
+ alert('Added ' + result.added + ' resources to group "' + this.compareGroupName + '"');
709
+
710
+ // Re-run comparison to update results
711
+ await this.runComparison();
712
+ } catch (error) {
713
+ alert('Error: ' + error.message);
714
+ } finally {
715
+ this.addingToGroup = false;
716
+ }
717
+ }
718
+ };
719
+ }
720
+ </script>
721
+ {% endblock %}