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,305 @@
1
+ """Resource groups API endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from fastapi import APIRouter, HTTPException, Query
8
+ from pydantic import BaseModel, field_validator
9
+
10
+ from ...dependencies import get_database, get_group_store
11
+
12
+ router = APIRouter(prefix="/groups")
13
+
14
+
15
+ class GroupMemberRequest(BaseModel):
16
+ """Request model for adding a group member."""
17
+
18
+ resource_name: str
19
+ resource_type: str
20
+ original_arn: Optional[str] = None
21
+
22
+
23
+ class AddMembersFromArnsRequest(BaseModel):
24
+ """Request model for adding members from ARNs."""
25
+
26
+ arns: List[Dict[str, str]] # List of {"arn": "...", "resource_type": "..."}
27
+
28
+
29
+ class CreateGroupRequest(BaseModel):
30
+ """Request model for creating a group."""
31
+
32
+ name: str
33
+ description: Optional[str] = ""
34
+ from_snapshot: Optional[str] = None
35
+ type_filter: Optional[str] = None
36
+ region_filter: Optional[str] = None
37
+
38
+ @field_validator("from_snapshot", "type_filter", "region_filter", mode="before")
39
+ @classmethod
40
+ def empty_string_to_none(cls, v):
41
+ """Convert empty strings to None for optional fields."""
42
+ if v == "":
43
+ return None
44
+ return v
45
+
46
+
47
+ class GroupResponse(BaseModel):
48
+ """Response model for a group."""
49
+
50
+ id: int
51
+ name: str
52
+ description: str
53
+ source_snapshot: Optional[str]
54
+ resource_count: int
55
+ is_favorite: bool
56
+ created_at: str
57
+ last_updated: str
58
+
59
+
60
+ class GroupCompareResponse(BaseModel):
61
+ """Response model for group comparison."""
62
+
63
+ group_name: str
64
+ snapshot_name: str
65
+ total_in_group: int
66
+ total_in_snapshot: int
67
+ matched: int
68
+ missing_from_snapshot: int
69
+ not_in_group: int
70
+ resources: Dict[str, List[Any]]
71
+
72
+
73
+ @router.get("")
74
+ async def list_groups(
75
+ favorites_only: bool = Query(False, description="Only show favorites"),
76
+ ):
77
+ """List all resource groups."""
78
+ store = get_group_store()
79
+ groups = store.list_all()
80
+ return {"groups": groups, "count": len(groups)}
81
+
82
+
83
+ @router.post("")
84
+ async def create_group(request: CreateGroupRequest):
85
+ """Create a new resource group.
86
+
87
+ If from_snapshot is provided, the group will be populated with resources
88
+ from that snapshot. Otherwise, an empty group is created.
89
+ """
90
+ store = get_group_store()
91
+
92
+ if store.exists(request.name):
93
+ raise HTTPException(status_code=400, detail=f"Group '{request.name}' already exists")
94
+
95
+ try:
96
+ if request.from_snapshot:
97
+ count = store.create_from_snapshot(
98
+ group_name=request.name,
99
+ snapshot_name=request.from_snapshot,
100
+ description=request.description or "",
101
+ type_filter=request.type_filter,
102
+ region_filter=request.region_filter,
103
+ )
104
+ return {
105
+ "message": f"Group '{request.name}' created with {count} resources",
106
+ "name": request.name,
107
+ "resource_count": count,
108
+ }
109
+ else:
110
+ from ....models.group import ResourceGroup
111
+
112
+ group = ResourceGroup(
113
+ name=request.name,
114
+ description=request.description or "",
115
+ )
116
+ group_id = store.save(group)
117
+ return {
118
+ "message": f"Empty group '{request.name}' created",
119
+ "name": request.name,
120
+ "id": group_id,
121
+ "resource_count": 0,
122
+ }
123
+ except ValueError as e:
124
+ raise HTTPException(status_code=400, detail=str(e))
125
+
126
+
127
+ @router.get("/{name}")
128
+ async def get_group(name: str):
129
+ """Get group details."""
130
+ store = get_group_store()
131
+ group = store.load(name)
132
+
133
+ if not group:
134
+ raise HTTPException(status_code=404, detail=f"Group '{name}' not found")
135
+
136
+ return {
137
+ "id": group.id,
138
+ "name": group.name,
139
+ "description": group.description,
140
+ "source_snapshot": group.source_snapshot,
141
+ "resource_count": group.resource_count,
142
+ "is_favorite": group.is_favorite,
143
+ "created_at": group.created_at.isoformat() if group.created_at else None,
144
+ "last_updated": group.last_updated.isoformat() if group.last_updated else None,
145
+ }
146
+
147
+
148
+ @router.delete("/{name}")
149
+ async def delete_group(name: str):
150
+ """Delete a resource group."""
151
+ store = get_group_store()
152
+
153
+ if not store.exists(name):
154
+ raise HTTPException(status_code=404, detail=f"Group '{name}' not found")
155
+
156
+ store.delete(name)
157
+ return {"message": f"Group '{name}' deleted"}
158
+
159
+
160
+ @router.post("/{name}/favorite")
161
+ async def toggle_group_favorite(name: str):
162
+ """Toggle the favorite status of a group."""
163
+ store = get_group_store()
164
+
165
+ new_favorite = store.toggle_favorite(name)
166
+ if new_favorite is None:
167
+ raise HTTPException(status_code=404, detail=f"Group '{name}' not found")
168
+
169
+ return {"message": "Favorite toggled", "is_favorite": new_favorite}
170
+
171
+
172
+ # Members endpoints
173
+
174
+
175
+ @router.get("/{name}/members")
176
+ async def get_group_members(
177
+ name: str,
178
+ limit: int = Query(100, le=1000),
179
+ offset: int = Query(0, ge=0),
180
+ ):
181
+ """Get members of a group with pagination."""
182
+ store = get_group_store()
183
+
184
+ if not store.exists(name):
185
+ raise HTTPException(status_code=404, detail=f"Group '{name}' not found")
186
+
187
+ members = store.get_members(name, limit=limit, offset=offset)
188
+
189
+ return {
190
+ "members": [
191
+ {
192
+ "resource_name": m.resource_name,
193
+ "resource_type": m.resource_type,
194
+ "original_arn": m.original_arn,
195
+ }
196
+ for m in members
197
+ ],
198
+ "count": len(members),
199
+ "limit": limit,
200
+ "offset": offset,
201
+ }
202
+
203
+
204
+ @router.post("/{name}/members")
205
+ async def add_group_members(name: str, request: AddMembersFromArnsRequest):
206
+ """Add members to a group from ARNs.
207
+
208
+ Each item in 'arns' should have 'arn' and 'resource_type' keys.
209
+ The resource name will be extracted from the ARN.
210
+ """
211
+ store = get_group_store()
212
+
213
+ if not store.exists(name):
214
+ raise HTTPException(status_code=404, detail=f"Group '{name}' not found")
215
+
216
+ try:
217
+ added = store.add_members_from_arns(name, request.arns)
218
+ return {"message": f"Added {added} members to group '{name}'", "added": added}
219
+ except ValueError as e:
220
+ raise HTTPException(status_code=400, detail=str(e))
221
+
222
+
223
+ @router.delete("/{name}/members")
224
+ async def remove_group_member(
225
+ name: str,
226
+ resource_name: str = Query(..., description="Resource name to remove"),
227
+ resource_type: str = Query(..., description="Resource type"),
228
+ ):
229
+ """Remove a member from a group."""
230
+ store = get_group_store()
231
+
232
+ if not store.exists(name):
233
+ raise HTTPException(status_code=404, detail=f"Group '{name}' not found")
234
+
235
+ removed = store.remove_member(name, resource_name, resource_type)
236
+
237
+ if removed:
238
+ return {"message": f"Removed '{resource_name}' from group '{name}'"}
239
+ else:
240
+ return {"message": "Member not found in group", "removed": False}
241
+
242
+
243
+ # Comparison endpoints
244
+
245
+
246
+ @router.get("/{name}/compare/{snapshot}")
247
+ async def compare_group_to_snapshot(name: str, snapshot: str):
248
+ """Compare a snapshot against a group.
249
+
250
+ Returns resources that are:
251
+ - matched: in both group and snapshot
252
+ - missing: in group but not in snapshot
253
+ - extra: in snapshot but not in group
254
+ """
255
+ store = get_group_store()
256
+
257
+ try:
258
+ result = store.compare_snapshot(name, snapshot)
259
+ return result
260
+ except ValueError as e:
261
+ raise HTTPException(status_code=404, detail=str(e))
262
+
263
+
264
+ @router.get("/{name}/resources/in")
265
+ async def get_resources_in_group(
266
+ name: str,
267
+ snapshot: str = Query(..., description="Snapshot name"),
268
+ limit: int = Query(100, le=1000),
269
+ offset: int = Query(0, ge=0),
270
+ ):
271
+ """Get resources from a snapshot that ARE in the group."""
272
+ store = get_group_store()
273
+
274
+ try:
275
+ resources = store.get_resources_in_group(name, snapshot, limit=limit, offset=offset)
276
+ return {
277
+ "resources": resources,
278
+ "count": len(resources),
279
+ "limit": limit,
280
+ "offset": offset,
281
+ }
282
+ except ValueError as e:
283
+ raise HTTPException(status_code=404, detail=str(e))
284
+
285
+
286
+ @router.get("/{name}/resources/not-in")
287
+ async def get_resources_not_in_group(
288
+ name: str,
289
+ snapshot: str = Query(..., description="Snapshot name"),
290
+ limit: int = Query(100, le=1000),
291
+ offset: int = Query(0, ge=0),
292
+ ):
293
+ """Get resources from a snapshot that are NOT in the group."""
294
+ store = get_group_store()
295
+
296
+ try:
297
+ resources = store.get_resources_not_in_group(name, snapshot, limit=limit, offset=offset)
298
+ return {
299
+ "resources": resources,
300
+ "count": len(resources),
301
+ "limit": limit,
302
+ "offset": offset,
303
+ }
304
+ except ValueError as e:
305
+ raise HTTPException(status_code=404, detail=str(e))
@@ -0,0 +1,80 @@
1
+ """Inventory API endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List, Optional
6
+
7
+ from fastapi import APIRouter, HTTPException
8
+ from pydantic import BaseModel
9
+
10
+ from ...dependencies import get_inventory_store, get_storage_path
11
+
12
+ router = APIRouter(prefix="/inventories")
13
+
14
+
15
+ class InventorySummary(BaseModel):
16
+ """Inventory summary for list view."""
17
+
18
+ name: str
19
+ account_id: str
20
+ description: str
21
+ snapshot_count: int
22
+ include_tags: dict
23
+ exclude_tags: dict
24
+
25
+
26
+ @router.get("")
27
+ async def list_inventories():
28
+ """List all inventories."""
29
+ store = get_inventory_store()
30
+ inventories = store.list_all()
31
+
32
+ return {
33
+ "inventories": [
34
+ {
35
+ "name": inv.name,
36
+ "account_id": inv.account_id,
37
+ "description": inv.description or "",
38
+ "snapshot_count": len(inv.snapshots) if inv.snapshots else 0,
39
+ "include_tags": inv.include_tags or {},
40
+ "exclude_tags": inv.exclude_tags or {},
41
+ "created_at": inv.created_at.isoformat() if hasattr(inv.created_at, 'isoformat') else str(inv.created_at),
42
+ }
43
+ for inv in inventories
44
+ ],
45
+ "count": len(inventories),
46
+ }
47
+
48
+
49
+ @router.get("/{name}")
50
+ async def get_inventory(name: str, account_id: Optional[str] = None):
51
+ """Get inventory details."""
52
+ store = get_inventory_store()
53
+
54
+ try:
55
+ # If account_id not provided, search all inventories
56
+ inventory = None
57
+ if account_id:
58
+ inventory = store.load(name, account_id)
59
+ else:
60
+ # Find first matching inventory by name
61
+ all_invs = store.list_all()
62
+ inventory = next((inv for inv in all_invs if inv.name == name), None)
63
+
64
+ if not inventory:
65
+ raise HTTPException(status_code=404, detail=f"Inventory '{name}' not found")
66
+
67
+ return {
68
+ "name": inventory.name,
69
+ "account_id": inventory.account_id,
70
+ "description": inventory.description or "",
71
+ "include_tags": inventory.include_tags or {},
72
+ "exclude_tags": inventory.exclude_tags or {},
73
+ "snapshots": inventory.snapshots or [],
74
+ "active_snapshot": inventory.active_snapshot,
75
+ "created_at": inventory.created_at.isoformat() if hasattr(inventory.created_at, 'isoformat') else str(inventory.created_at),
76
+ }
77
+ except HTTPException:
78
+ raise
79
+ except Exception as e:
80
+ raise HTTPException(status_code=404, detail=str(e))
@@ -0,0 +1,202 @@
1
+ """Saved queries API endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import List, Optional
7
+
8
+ from fastapi import APIRouter, HTTPException, Query
9
+ from pydantic import BaseModel
10
+
11
+ from ...dependencies import get_database, get_resource_store
12
+
13
+ router = APIRouter(prefix="/queries")
14
+
15
+
16
+ class SavedQuery(BaseModel):
17
+ """Saved query model."""
18
+
19
+ id: Optional[int] = None
20
+ name: str
21
+ description: Optional[str] = None
22
+ sql_text: str
23
+ category: str = "custom"
24
+ is_favorite: bool = False
25
+
26
+
27
+ class QueryResult(BaseModel):
28
+ """Query execution result."""
29
+
30
+ columns: List[str]
31
+ rows: List[dict]
32
+ row_count: int
33
+ execution_time_ms: float
34
+
35
+
36
+ @router.get("")
37
+ async def list_saved_queries(
38
+ category: Optional[str] = Query(None, description="Filter by category"),
39
+ favorites_only: bool = Query(False, description="Show only favorites"),
40
+ ):
41
+ """List saved queries."""
42
+ db = get_database()
43
+
44
+ sql = "SELECT * FROM saved_queries WHERE 1=1"
45
+ params: List = []
46
+
47
+ if category:
48
+ sql += " AND category = ?"
49
+ params.append(category)
50
+
51
+ if favorites_only:
52
+ sql += " AND is_favorite = 1"
53
+
54
+ sql += " ORDER BY is_favorite DESC, last_run_at DESC NULLS LAST, name"
55
+
56
+ rows = db.fetchall(sql, tuple(params))
57
+ return {"queries": [dict(r) for r in rows]}
58
+
59
+
60
+ @router.post("")
61
+ async def create_saved_query(query: SavedQuery):
62
+ """Save a new query."""
63
+ db = get_database()
64
+
65
+ try:
66
+ cursor = db.execute(
67
+ """
68
+ INSERT INTO saved_queries (name, description, sql_text, category, is_favorite, created_at)
69
+ VALUES (?, ?, ?, ?, ?, ?)
70
+ """,
71
+ (
72
+ query.name,
73
+ query.description,
74
+ query.sql_text,
75
+ query.category,
76
+ query.is_favorite,
77
+ datetime.utcnow().isoformat(),
78
+ ),
79
+ )
80
+ db._conn.commit() # type: ignore
81
+ return {"id": cursor.lastrowid, "message": "Query saved"}
82
+ except Exception as e:
83
+ if "UNIQUE constraint" in str(e):
84
+ raise HTTPException(status_code=400, detail=f"Query with name '{query.name}' already exists")
85
+ raise HTTPException(status_code=500, detail=str(e))
86
+
87
+
88
+ @router.get("/{query_id}")
89
+ async def get_saved_query(query_id: int):
90
+ """Get a saved query by ID."""
91
+ db = get_database()
92
+ row = db.fetchone("SELECT * FROM saved_queries WHERE id = ?", (query_id,))
93
+
94
+ if not row:
95
+ raise HTTPException(status_code=404, detail="Query not found")
96
+
97
+ return dict(row)
98
+
99
+
100
+ @router.put("/{query_id}")
101
+ async def update_saved_query(query_id: int, query: SavedQuery):
102
+ """Update a saved query."""
103
+ db = get_database()
104
+
105
+ existing = db.fetchone("SELECT id FROM saved_queries WHERE id = ?", (query_id,))
106
+ if not existing:
107
+ raise HTTPException(status_code=404, detail="Query not found")
108
+
109
+ db.execute(
110
+ """
111
+ UPDATE saved_queries
112
+ SET name = ?, description = ?, sql_text = ?, category = ?, is_favorite = ?
113
+ WHERE id = ?
114
+ """,
115
+ (query.name, query.description, query.sql_text, query.category, query.is_favorite, query_id),
116
+ )
117
+ db._conn.commit() # type: ignore
118
+ return {"message": "Query updated"}
119
+
120
+
121
+ @router.delete("/{query_id}")
122
+ async def delete_saved_query(query_id: int):
123
+ """Delete a saved query."""
124
+ db = get_database()
125
+
126
+ existing = db.fetchone("SELECT id FROM saved_queries WHERE id = ?", (query_id,))
127
+ if not existing:
128
+ raise HTTPException(status_code=404, detail="Query not found")
129
+
130
+ db.execute("DELETE FROM saved_queries WHERE id = ?", (query_id,))
131
+ db._conn.commit() # type: ignore
132
+ return {"message": "Query deleted"}
133
+
134
+
135
+ @router.post("/{query_id}/run")
136
+ async def run_saved_query(
137
+ query_id: int,
138
+ limit: int = Query(100, le=1000),
139
+ ):
140
+ """Execute a saved query."""
141
+ db = get_database()
142
+
143
+ row = db.fetchone("SELECT * FROM saved_queries WHERE id = ?", (query_id,))
144
+ if not row:
145
+ raise HTTPException(status_code=404, detail="Query not found")
146
+
147
+ sql_text = row["sql_text"]
148
+
149
+ # Update last_run_at and run_count
150
+ db.execute(
151
+ """
152
+ UPDATE saved_queries
153
+ SET last_run_at = ?, run_count = run_count + 1
154
+ WHERE id = ?
155
+ """,
156
+ (datetime.utcnow().isoformat(), query_id),
157
+ )
158
+ db._conn.commit() # type: ignore
159
+
160
+ # Execute the query
161
+ return await _execute_sql(sql_text, limit)
162
+
163
+
164
+ @router.post("/execute")
165
+ async def execute_sql(
166
+ sql: str,
167
+ limit: int = Query(100, le=1000),
168
+ ):
169
+ """Execute an ad-hoc SQL query."""
170
+ return await _execute_sql(sql, limit)
171
+
172
+
173
+ async def _execute_sql(sql: str, limit: int) -> dict:
174
+ """Execute SQL and return results."""
175
+ import time
176
+
177
+ store = get_resource_store()
178
+
179
+ # Add limit if not present
180
+ sql_lower = sql.lower().strip()
181
+ if "limit" not in sql_lower:
182
+ sql = f"{sql.rstrip(';')} LIMIT {limit}"
183
+
184
+ start = time.time()
185
+
186
+ try:
187
+ results = store.query_raw(sql)
188
+ elapsed = (time.time() - start) * 1000
189
+
190
+ if results:
191
+ columns = list(results[0].keys())
192
+ else:
193
+ columns = []
194
+
195
+ return {
196
+ "columns": columns,
197
+ "rows": results,
198
+ "row_count": len(results),
199
+ "execution_time_ms": round(elapsed, 2),
200
+ }
201
+ except Exception as e:
202
+ raise HTTPException(status_code=400, detail=str(e))