stackport 0.3.9__tar.gz → 0.4.1__tar.gz
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.
- {stackport-0.3.9/stackport.egg-info → stackport-0.4.1}/PKG-INFO +6 -17
- {stackport-0.3.9 → stackport-0.4.1}/README.md +5 -16
- {stackport-0.3.9 → stackport-0.4.1}/backend/main.py +12 -2
- stackport-0.4.1/backend/routes/monitoring.py +131 -0
- stackport-0.4.1/backend/routes/sns.py +210 -0
- stackport-0.4.1/backend/schemas/monitoring.py +28 -0
- stackport-0.4.1/backend/schemas/sns.py +38 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/websocket.py +133 -0
- {stackport-0.3.9 → stackport-0.4.1}/pyproject.toml +1 -1
- {stackport-0.3.9 → stackport-0.4.1/stackport.egg-info}/PKG-INFO +6 -17
- {stackport-0.3.9 → stackport-0.4.1}/stackport.egg-info/SOURCES.txt +49 -6
- stackport-0.4.1/tests/test_logs_tail.py +104 -0
- stackport-0.4.1/tests/test_monitoring_routes.py +152 -0
- stackport-0.4.1/tests/test_sns_routes.py +197 -0
- stackport-0.4.1/ui/dist/assets/CloudscapeAbout-BFh7x4BC.js +1 -0
- stackport-0.4.1/ui/dist/assets/CloudscapeDashboard-DzRbBwTo.js +1 -0
- stackport-0.4.1/ui/dist/assets/CloudscapeResourceBrowser-DNa6P8a7.css +1 -0
- stackport-0.4.1/ui/dist/assets/CloudscapeResourceBrowser-DZE3EncJ.js +19 -0
- stackport-0.4.1/ui/dist/assets/CloudscapeSettings-BOzr9FgE.js +1 -0
- stackport-0.4.1/ui/dist/assets/CloudscapeShell-C1R1SIYZ.css +1 -0
- stackport-0.4.1/ui/dist/assets/CloudscapeShell-KCrV2gOE.js +201 -0
- stackport-0.4.1/ui/dist/assets/StateMachineGraph-wwDpbmDc.js +21 -0
- stackport-0.4.1/ui/dist/assets/index-BKtAPb3s.js +1 -0
- stackport-0.4.1/ui/dist/assets/index-BVHxAbst.js +1 -0
- stackport-0.4.1/ui/dist/assets/index-C_MN8WBt.js +1 -0
- stackport-0.4.1/ui/dist/assets/index-Cah5DqCf.css +1 -0
- stackport-0.4.1/ui/dist/assets/index-D7KAiUgR.js +52 -0
- stackport-0.4.1/ui/dist/assets/index-DE--A2EE.css +1 -0
- stackport-0.4.1/ui/dist/assets/index-DRZKHoZl.css +1 -0
- stackport-0.4.1/ui/dist/assets/index-DaXIyw62.css +1 -0
- stackport-0.4.1/ui/dist/assets/index-F7IxH4zH.js +1 -0
- stackport-0.4.1/ui/dist/assets/index-bpZOyX6N.js +1 -0
- stackport-0.4.1/ui/dist/assets/index-siFAyqi4.css +1 -0
- stackport-0.4.1/ui/dist/assets/utils-Cfmwk2UT.js +1 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/index.html +2 -2
- stackport-0.4.1/ui/dist/stackport-logo.svg +68 -0
- stackport-0.3.9/ui/dist/assets/StateMachineGraph-BGBIYpUV.js +0 -6
- stackport-0.3.9/ui/dist/assets/index-BZr5kWvs.css +0 -1
- stackport-0.3.9/ui/dist/assets/index-iwgw2OEV.js +0 -652
- {stackport-0.3.9 → stackport-0.4.1}/LICENSE +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/MANIFEST.in +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/__init__.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/aws_client.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/cache.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/cli.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/config.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/endpoint_store.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/routes/__init__.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/routes/common.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/routes/dynamodb.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/routes/ec2.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/routes/endpoints.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/routes/iam.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/routes/lambda_svc.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/routes/logs.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/routes/rds.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/routes/resources.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/routes/s3.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/routes/secretsmanager.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/routes/sqs.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/routes/stats.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/routes/stepfunctions.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/routes/tags.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/schemas/__init__.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/schemas/dynamodb.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/schemas/endpoints.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/schemas/lambda_svc.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/schemas/logs.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/schemas/s3.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/schemas/secretsmanager.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/schemas/sqs.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/schemas/stepfunctions.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/backend/schemas/tags.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/setup.cfg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/stackport.egg-info/dependency_links.txt +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/stackport.egg-info/entry_points.txt +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/stackport.egg-info/requires.txt +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/stackport.egg-info/top_level.txt +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/tests/test_cache.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/tests/test_cli.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/tests/test_client.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/tests/test_config.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/tests/test_dynamodb_routes.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/tests/test_ec2_routes.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/tests/test_endpoint_store.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/tests/test_endpoints.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/tests/test_iam_routes.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/tests/test_lambda_routes.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/tests/test_logs_routes.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/tests/test_multi_endpoint.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/tests/test_rds_routes.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/tests/test_readonly_middleware.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/tests/test_registries.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/tests/test_routes.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/tests/test_s3_routes.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/tests/test_s3_upload_limit_env.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/tests/test_secretsmanager_routes.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/tests/test_sqs_routes.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/tests/test_tags_routes.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/tests/test_websocket.py +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/assets/stepfunctions-graph-B6nvU4OB.js +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/acm.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/apigateway.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/appsync.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/athena.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/cloudformation.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/cloudfront.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/cognito-idp.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/dynamodb.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/ec2.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/ecr.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/ecs.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/elasticache.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/events.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/firehose.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/glue.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/iam.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/kinesis.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/kms.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/lambda.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/logs.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/monitoring.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/rds.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/route53.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/s3.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/secretsmanager.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/ses.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/sns.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/sqs.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/ssm.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/stepfunctions.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/wafv2.svg +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/favicon.png +0 -0
- {stackport-0.3.9 → stackport-0.4.1}/ui/dist/favicon.svg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackport
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Universal AWS resource browser for local emulators
|
|
5
5
|
Author: Davi Reis Vieira
|
|
6
6
|
License: MIT
|
|
@@ -55,13 +55,13 @@ Dynamic: license-file
|
|
|
55
55
|
## Screenshots
|
|
56
56
|
|
|
57
57
|
**Dashboard** — Service overview with resource counts and health status
|
|
58
|
-

|
|
59
59
|
|
|
60
60
|
**Lambda Browser** — Function list with config, aliases, versions, event sources, and invocation panel
|
|
61
|
-

|
|
62
62
|
|
|
63
63
|
**S3 Browser** — File browser with upload, download, folder navigation, and tagging
|
|
64
|
-

|
|
65
65
|
|
|
66
66
|
## Features
|
|
67
67
|
|
|
@@ -326,24 +326,13 @@ backend/
|
|
|
326
326
|
secretsmanager.py Secret value retrieval
|
|
327
327
|
|
|
328
328
|
ui/src/
|
|
329
|
-
pages/
|
|
329
|
+
pages/ CloudscapeDashboard, CloudscapeResourceBrowser, CloudscapeSettings, CloudscapeAbout
|
|
330
330
|
components/
|
|
331
|
-
service
|
|
332
|
-
ui/ shadcn/ui components (Radix-based)
|
|
331
|
+
cloudscape/ AppLayout shell + service views (Cloudscape Design System)
|
|
333
332
|
hooks/ useFetch, useWebSocket, useKeyboardShortcuts, ...
|
|
334
333
|
lib/ API client, types, service icons, utils
|
|
335
334
|
```
|
|
336
335
|
|
|
337
|
-
## Star History
|
|
338
|
-
|
|
339
|
-
<a href="https://star-history.com/#DaviReisVieira/stackport&Date">
|
|
340
|
-
<picture>
|
|
341
|
-
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=DaviReisVieira/stackport&type=Date&theme=dark" />
|
|
342
|
-
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=DaviReisVieira/stackport&type=Date" />
|
|
343
|
-
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=DaviReisVieira/stackport&type=Date" width="100%" />
|
|
344
|
-
</picture>
|
|
345
|
-
</a>
|
|
346
|
-
|
|
347
336
|
## Contributors
|
|
348
337
|
|
|
349
338
|
<a href="https://github.com/DaviReisVieira/stackport/graphs/contributors">
|
|
@@ -19,13 +19,13 @@
|
|
|
19
19
|
## Screenshots
|
|
20
20
|
|
|
21
21
|
**Dashboard** — Service overview with resource counts and health status
|
|
22
|
-

|
|
23
23
|
|
|
24
24
|
**Lambda Browser** — Function list with config, aliases, versions, event sources, and invocation panel
|
|
25
|
-

|
|
26
26
|
|
|
27
27
|
**S3 Browser** — File browser with upload, download, folder navigation, and tagging
|
|
28
|
-

|
|
29
29
|
|
|
30
30
|
## Features
|
|
31
31
|
|
|
@@ -290,24 +290,13 @@ backend/
|
|
|
290
290
|
secretsmanager.py Secret value retrieval
|
|
291
291
|
|
|
292
292
|
ui/src/
|
|
293
|
-
pages/
|
|
293
|
+
pages/ CloudscapeDashboard, CloudscapeResourceBrowser, CloudscapeSettings, CloudscapeAbout
|
|
294
294
|
components/
|
|
295
|
-
service
|
|
296
|
-
ui/ shadcn/ui components (Radix-based)
|
|
295
|
+
cloudscape/ AppLayout shell + service views (Cloudscape Design System)
|
|
297
296
|
hooks/ useFetch, useWebSocket, useKeyboardShortcuts, ...
|
|
298
297
|
lib/ API client, types, service icons, utils
|
|
299
298
|
```
|
|
300
299
|
|
|
301
|
-
## Star History
|
|
302
|
-
|
|
303
|
-
<a href="https://star-history.com/#DaviReisVieira/stackport&Date">
|
|
304
|
-
<picture>
|
|
305
|
-
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=DaviReisVieira/stackport&type=Date&theme=dark" />
|
|
306
|
-
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=DaviReisVieira/stackport&type=Date" />
|
|
307
|
-
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=DaviReisVieira/stackport&type=Date" width="100%" />
|
|
308
|
-
</picture>
|
|
309
|
-
</a>
|
|
310
|
-
|
|
311
300
|
## Contributors
|
|
312
301
|
|
|
313
302
|
<a href="https://github.com/DaviReisVieira/stackport/graphs/contributors">
|
|
@@ -17,8 +17,8 @@ from botocore.exceptions import (
|
|
|
17
17
|
)
|
|
18
18
|
|
|
19
19
|
from backend.config import LOG_LEVEL, STACKPORT_ALLOW_WRITES, STACKPORT_PORT
|
|
20
|
-
from backend.routes import dynamodb, ec2, endpoints, iam, lambda_svc, logs, resources, rds, s3, secretsmanager, sqs, stats, stepfunctions, tags
|
|
21
|
-
from backend.websocket import probe_loop, websocket_endpoint
|
|
20
|
+
from backend.routes import dynamodb, ec2, endpoints, iam, lambda_svc, logs, monitoring, resources, rds, s3, secretsmanager, sns, sqs, stats, stepfunctions, tags
|
|
21
|
+
from backend.websocket import logs_tail_endpoint, probe_loop, websocket_endpoint
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
class HealthcheckFilter(logging.Filter):
|
|
@@ -71,6 +71,8 @@ class ReadOnlyMiddleware(BaseHTTPMiddleware):
|
|
|
71
71
|
return True
|
|
72
72
|
if path.startswith("/api/lambda/functions/") and path.endswith("/invoke"):
|
|
73
73
|
return True
|
|
74
|
+
if path == "/api/monitoring/metric-data":
|
|
75
|
+
return True
|
|
74
76
|
# Step Functions start/stop execution are writes, not reads
|
|
75
77
|
return False
|
|
76
78
|
|
|
@@ -127,6 +129,8 @@ app.include_router(stepfunctions.router, prefix="/api/stepfunctions", tags=["ste
|
|
|
127
129
|
app.include_router(tags.router, prefix="/api", tags=["tags"])
|
|
128
130
|
app.include_router(resources.router, prefix="/api")
|
|
129
131
|
app.include_router(rds.router, prefix="/api/rds", tags=["rds"])
|
|
132
|
+
app.include_router(monitoring.router, prefix="/api/monitoring", tags=["monitoring"])
|
|
133
|
+
app.include_router(sns.router, prefix="/api/sns", tags=["sns"])
|
|
130
134
|
|
|
131
135
|
|
|
132
136
|
# WebSocket endpoint for real-time updates
|
|
@@ -135,6 +139,12 @@ async def ws(websocket: WebSocket):
|
|
|
135
139
|
await websocket_endpoint(websocket)
|
|
136
140
|
|
|
137
141
|
|
|
142
|
+
# WebSocket endpoint for live log tailing (#85)
|
|
143
|
+
@app.websocket("/ws/logs/tail")
|
|
144
|
+
async def ws_logs_tail(websocket: WebSocket):
|
|
145
|
+
await logs_tail_endpoint(websocket)
|
|
146
|
+
|
|
147
|
+
|
|
138
148
|
# Serve UI static files — mount assets under /assets, SPA fallback for everything else
|
|
139
149
|
ui_dist = os.path.join(os.path.dirname(__file__), "..", "ui", "dist")
|
|
140
150
|
if os.path.isdir(ui_dist):
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""CloudWatch monitoring routes: dashboards, alarms and metric data (#123)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
8
|
+
|
|
9
|
+
from backend.aws_client import get_client
|
|
10
|
+
from backend.routes.common import EndpointInfo, get_endpoint_info
|
|
11
|
+
from backend.schemas.monitoring import MetricDataRequest
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
router = APIRouter()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _iso(value) -> str | None:
|
|
19
|
+
if value is None:
|
|
20
|
+
return None
|
|
21
|
+
if isinstance(value, datetime):
|
|
22
|
+
return value.isoformat()
|
|
23
|
+
return str(value)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@router.get("/dashboards")
|
|
27
|
+
def list_dashboards(ep: EndpointInfo = Depends(get_endpoint_info)):
|
|
28
|
+
"""List CloudWatch dashboards."""
|
|
29
|
+
cw = get_client("cloudwatch", **ep.client_kwargs())
|
|
30
|
+
response = cw.list_dashboards()
|
|
31
|
+
dashboards = [
|
|
32
|
+
{
|
|
33
|
+
"name": entry["DashboardName"],
|
|
34
|
+
"lastModified": _iso(entry.get("LastModified")),
|
|
35
|
+
"size": entry.get("Size", 0),
|
|
36
|
+
}
|
|
37
|
+
for entry in response.get("DashboardEntries", [])
|
|
38
|
+
]
|
|
39
|
+
return {"dashboards": dashboards}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.get("/dashboards/{name}")
|
|
43
|
+
def get_dashboard(name: str, ep: EndpointInfo = Depends(get_endpoint_info)):
|
|
44
|
+
"""Get a dashboard with its parsed widget definitions."""
|
|
45
|
+
cw = get_client("cloudwatch", **ep.client_kwargs())
|
|
46
|
+
try:
|
|
47
|
+
response = cw.get_dashboard(DashboardName=name)
|
|
48
|
+
except Exception as exc:
|
|
49
|
+
raise HTTPException(status_code=404, detail=f"Dashboard '{name}' not found: {exc}")
|
|
50
|
+
try:
|
|
51
|
+
body = json.loads(response.get("DashboardBody") or "{}")
|
|
52
|
+
except json.JSONDecodeError:
|
|
53
|
+
body = {}
|
|
54
|
+
return {"name": name, "body": body}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@router.get("/alarms")
|
|
58
|
+
def list_alarms(ep: EndpointInfo = Depends(get_endpoint_info)):
|
|
59
|
+
"""List CloudWatch metric alarms with their state and configuration."""
|
|
60
|
+
cw = get_client("cloudwatch", **ep.client_kwargs())
|
|
61
|
+
response = cw.describe_alarms()
|
|
62
|
+
alarms = [
|
|
63
|
+
{
|
|
64
|
+
"name": alarm["AlarmName"],
|
|
65
|
+
"arn": alarm.get("AlarmArn", ""),
|
|
66
|
+
"description": alarm.get("AlarmDescription"),
|
|
67
|
+
"state": alarm.get("StateValue", "INSUFFICIENT_DATA"),
|
|
68
|
+
"stateReason": alarm.get("StateReason"),
|
|
69
|
+
"stateUpdated": _iso(alarm.get("StateUpdatedTimestamp")),
|
|
70
|
+
"namespace": alarm.get("Namespace"),
|
|
71
|
+
"metricName": alarm.get("MetricName"),
|
|
72
|
+
"statistic": alarm.get("Statistic"),
|
|
73
|
+
"period": alarm.get("Period"),
|
|
74
|
+
"evaluationPeriods": alarm.get("EvaluationPeriods"),
|
|
75
|
+
"threshold": alarm.get("Threshold"),
|
|
76
|
+
"comparisonOperator": alarm.get("ComparisonOperator"),
|
|
77
|
+
"dimensions": [
|
|
78
|
+
{"name": d.get("Name", ""), "value": d.get("Value", "")} for d in alarm.get("Dimensions", [])
|
|
79
|
+
],
|
|
80
|
+
}
|
|
81
|
+
for alarm in response.get("MetricAlarms", [])
|
|
82
|
+
]
|
|
83
|
+
return {"alarms": alarms}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.post("/metric-data")
|
|
87
|
+
def get_metric_data(body: MetricDataRequest, ep: EndpointInfo = Depends(get_endpoint_info)):
|
|
88
|
+
"""Fetch metric time series for dashboard widgets.
|
|
89
|
+
|
|
90
|
+
This is a read-only POST (the query set does not fit in a query string);
|
|
91
|
+
it is allow-listed in the read-only middleware.
|
|
92
|
+
"""
|
|
93
|
+
if not body.queries:
|
|
94
|
+
return {"results": []}
|
|
95
|
+
cw = get_client("cloudwatch", **ep.client_kwargs())
|
|
96
|
+
|
|
97
|
+
end = datetime.now(timezone.utc)
|
|
98
|
+
start = end - timedelta(minutes=max(1, body.start_minutes))
|
|
99
|
+
|
|
100
|
+
metric_queries = [
|
|
101
|
+
{
|
|
102
|
+
"Id": query.id,
|
|
103
|
+
"MetricStat": {
|
|
104
|
+
"Metric": {
|
|
105
|
+
"Namespace": query.namespace,
|
|
106
|
+
"MetricName": query.metric_name,
|
|
107
|
+
"Dimensions": [{"Name": d.name, "Value": d.value} for d in query.dimensions],
|
|
108
|
+
},
|
|
109
|
+
"Period": max(1, query.period),
|
|
110
|
+
"Stat": query.stat,
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
for query in body.queries[:100]
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
response = cw.get_metric_data(MetricDataQueries=metric_queries, StartTime=start, EndTime=end)
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
logger.debug("get_metric_data failed", exc_info=True)
|
|
120
|
+
raise HTTPException(status_code=502, detail=f"Failed to fetch metric data: {exc}")
|
|
121
|
+
|
|
122
|
+
results = [
|
|
123
|
+
{
|
|
124
|
+
"id": series.get("Id", ""),
|
|
125
|
+
"label": series.get("Label", ""),
|
|
126
|
+
"timestamps": [_iso(ts) for ts in series.get("Timestamps", [])],
|
|
127
|
+
"values": series.get("Values", []),
|
|
128
|
+
}
|
|
129
|
+
for series in response.get("MetricDataResults", [])
|
|
130
|
+
]
|
|
131
|
+
return {"results": results}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""SNS routes: topics, subscriptions and publishing (#75)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
7
|
+
|
|
8
|
+
from backend.aws_client import get_client
|
|
9
|
+
from backend.routes.common import EndpointInfo, get_endpoint_info
|
|
10
|
+
from backend.schemas.sns import CreateTopicBody, PublishBody, SubscribeBody
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _topic_name(arn: str) -> str:
|
|
18
|
+
return arn.rsplit(":", 1)[-1]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _serialize_topic(arn: str, attributes: dict) -> dict:
|
|
22
|
+
return {
|
|
23
|
+
"arn": arn,
|
|
24
|
+
"name": _topic_name(arn),
|
|
25
|
+
"displayName": attributes.get("DisplayName") or None,
|
|
26
|
+
"fifo": attributes.get("FifoTopic") == "true",
|
|
27
|
+
"contentBasedDeduplication": attributes.get("ContentBasedDeduplication") == "true",
|
|
28
|
+
"subscriptionsConfirmed": int(attributes.get("SubscriptionsConfirmed") or 0),
|
|
29
|
+
"subscriptionsPending": int(attributes.get("SubscriptionsPending") or 0),
|
|
30
|
+
"owner": attributes.get("Owner"),
|
|
31
|
+
"kmsMasterKeyId": attributes.get("KmsMasterKeyId"),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _serialize_subscription(sns, sub: dict) -> dict:
|
|
36
|
+
entry = {
|
|
37
|
+
"arn": sub.get("SubscriptionArn", ""),
|
|
38
|
+
"protocol": sub.get("Protocol", ""),
|
|
39
|
+
"endpoint": sub.get("Endpoint", ""),
|
|
40
|
+
"owner": sub.get("Owner", ""),
|
|
41
|
+
"pending": sub.get("SubscriptionArn") == "PendingConfirmation",
|
|
42
|
+
"filterPolicy": None,
|
|
43
|
+
"rawMessageDelivery": False,
|
|
44
|
+
}
|
|
45
|
+
if not entry["pending"] and entry["arn"]:
|
|
46
|
+
try:
|
|
47
|
+
attrs = sns.get_subscription_attributes(SubscriptionArn=entry["arn"]).get("Attributes", {})
|
|
48
|
+
if attrs.get("FilterPolicy"):
|
|
49
|
+
try:
|
|
50
|
+
entry["filterPolicy"] = json.loads(attrs["FilterPolicy"])
|
|
51
|
+
except json.JSONDecodeError:
|
|
52
|
+
entry["filterPolicy"] = attrs["FilterPolicy"]
|
|
53
|
+
entry["rawMessageDelivery"] = attrs.get("RawMessageDelivery") == "true"
|
|
54
|
+
except Exception:
|
|
55
|
+
logger.debug("Failed to fetch subscription attributes for %s", entry["arn"], exc_info=True)
|
|
56
|
+
return entry
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _list_all_topic_arns(sns) -> list[str]:
|
|
60
|
+
"""list_topics returns at most 100 entries per call; follow NextToken."""
|
|
61
|
+
arns: list[str] = []
|
|
62
|
+
token: str | None = None
|
|
63
|
+
while True:
|
|
64
|
+
response = sns.list_topics(NextToken=token) if token else sns.list_topics()
|
|
65
|
+
arns.extend(t["TopicArn"] for t in response.get("Topics", []))
|
|
66
|
+
token = response.get("NextToken")
|
|
67
|
+
if not token:
|
|
68
|
+
return arns
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _list_all_subscriptions(sns, topic_arn: str) -> list[dict]:
|
|
72
|
+
"""list_subscriptions_by_topic paginates the same way."""
|
|
73
|
+
subs: list[dict] = []
|
|
74
|
+
token: str | None = None
|
|
75
|
+
while True:
|
|
76
|
+
if token:
|
|
77
|
+
response = sns.list_subscriptions_by_topic(TopicArn=topic_arn, NextToken=token)
|
|
78
|
+
else:
|
|
79
|
+
response = sns.list_subscriptions_by_topic(TopicArn=topic_arn)
|
|
80
|
+
subs.extend(response.get("Subscriptions", []))
|
|
81
|
+
token = response.get("NextToken")
|
|
82
|
+
if not token:
|
|
83
|
+
return subs
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.get("/topics")
|
|
87
|
+
def list_topics(ep: EndpointInfo = Depends(get_endpoint_info)):
|
|
88
|
+
"""List SNS topics enriched with their attributes."""
|
|
89
|
+
sns = get_client("sns", **ep.client_kwargs())
|
|
90
|
+
topics = []
|
|
91
|
+
for arn in _list_all_topic_arns(sns):
|
|
92
|
+
try:
|
|
93
|
+
attributes = sns.get_topic_attributes(TopicArn=arn).get("Attributes", {})
|
|
94
|
+
except Exception:
|
|
95
|
+
logger.debug("Failed to fetch attributes for topic %s", arn, exc_info=True)
|
|
96
|
+
attributes = {}
|
|
97
|
+
topics.append(_serialize_topic(arn, attributes))
|
|
98
|
+
return {"topics": topics}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@router.get("/topics/{arn}")
|
|
102
|
+
def get_topic(arn: str, ep: EndpointInfo = Depends(get_endpoint_info)):
|
|
103
|
+
"""Get a topic's attributes and subscriptions."""
|
|
104
|
+
sns = get_client("sns", **ep.client_kwargs())
|
|
105
|
+
try:
|
|
106
|
+
attributes = sns.get_topic_attributes(TopicArn=arn).get("Attributes", {})
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
raise HTTPException(status_code=404, detail=f"Topic not found: {exc}")
|
|
109
|
+
|
|
110
|
+
subscriptions = [_serialize_subscription(sns, sub) for sub in _list_all_subscriptions(sns, arn)]
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
**_serialize_topic(arn, attributes),
|
|
114
|
+
"attributes": attributes,
|
|
115
|
+
"subscriptions": subscriptions,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@router.post("/topics", status_code=201)
|
|
120
|
+
def create_topic(body: CreateTopicBody, ep: EndpointInfo = Depends(get_endpoint_info)):
|
|
121
|
+
"""Create a Standard or FIFO topic."""
|
|
122
|
+
sns = get_client("sns", **ep.client_kwargs())
|
|
123
|
+
name = body.name
|
|
124
|
+
attributes: dict[str, str] = {}
|
|
125
|
+
if body.fifo:
|
|
126
|
+
if not name.endswith(".fifo"):
|
|
127
|
+
name = f"{name}.fifo"
|
|
128
|
+
attributes["FifoTopic"] = "true"
|
|
129
|
+
if body.content_based_deduplication:
|
|
130
|
+
attributes["ContentBasedDeduplication"] = "true"
|
|
131
|
+
if body.display_name:
|
|
132
|
+
attributes["DisplayName"] = body.display_name
|
|
133
|
+
|
|
134
|
+
params: dict = {"Name": name}
|
|
135
|
+
if attributes:
|
|
136
|
+
params["Attributes"] = attributes
|
|
137
|
+
try:
|
|
138
|
+
response = sns.create_topic(**params)
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
raise HTTPException(status_code=400, detail=f"Failed to create topic: {exc}")
|
|
141
|
+
return {"arn": response["TopicArn"], "name": name}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@router.delete("/topics/{arn}")
|
|
145
|
+
def delete_topic(arn: str, ep: EndpointInfo = Depends(get_endpoint_info)):
|
|
146
|
+
"""Delete a topic."""
|
|
147
|
+
sns = get_client("sns", **ep.client_kwargs())
|
|
148
|
+
try:
|
|
149
|
+
sns.delete_topic(TopicArn=arn)
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
raise HTTPException(status_code=400, detail=f"Failed to delete topic: {exc}")
|
|
152
|
+
return {"deleted": True, "arn": arn}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@router.post("/topics/{arn}/subscriptions", status_code=201)
|
|
156
|
+
def subscribe(arn: str, body: SubscribeBody, ep: EndpointInfo = Depends(get_endpoint_info)):
|
|
157
|
+
"""Subscribe an endpoint to a topic."""
|
|
158
|
+
sns = get_client("sns", **ep.client_kwargs())
|
|
159
|
+
attributes: dict[str, str] = {}
|
|
160
|
+
if body.filter_policy:
|
|
161
|
+
attributes["FilterPolicy"] = json.dumps(body.filter_policy)
|
|
162
|
+
if body.raw_message_delivery:
|
|
163
|
+
attributes["RawMessageDelivery"] = "true"
|
|
164
|
+
params: dict = {
|
|
165
|
+
"TopicArn": arn,
|
|
166
|
+
"Protocol": body.protocol,
|
|
167
|
+
"Endpoint": body.endpoint,
|
|
168
|
+
"ReturnSubscriptionArn": True,
|
|
169
|
+
}
|
|
170
|
+
if attributes:
|
|
171
|
+
params["Attributes"] = attributes
|
|
172
|
+
try:
|
|
173
|
+
response = sns.subscribe(**params)
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
raise HTTPException(status_code=400, detail=f"Failed to subscribe: {exc}")
|
|
176
|
+
return {"subscriptionArn": response.get("SubscriptionArn", "")}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@router.delete("/subscriptions/{arn}")
|
|
180
|
+
def unsubscribe(arn: str, ep: EndpointInfo = Depends(get_endpoint_info)):
|
|
181
|
+
"""Remove a subscription."""
|
|
182
|
+
sns = get_client("sns", **ep.client_kwargs())
|
|
183
|
+
try:
|
|
184
|
+
sns.unsubscribe(SubscriptionArn=arn)
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
raise HTTPException(status_code=400, detail=f"Failed to unsubscribe: {exc}")
|
|
187
|
+
return {"deleted": True, "arn": arn}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@router.post("/topics/{arn}/publish")
|
|
191
|
+
def publish(arn: str, body: PublishBody, ep: EndpointInfo = Depends(get_endpoint_info)):
|
|
192
|
+
"""Publish a message to a topic."""
|
|
193
|
+
sns = get_client("sns", **ep.client_kwargs())
|
|
194
|
+
params: dict = {"TopicArn": arn, "Message": body.message}
|
|
195
|
+
if body.subject:
|
|
196
|
+
params["Subject"] = body.subject
|
|
197
|
+
if body.message_group_id:
|
|
198
|
+
params["MessageGroupId"] = body.message_group_id
|
|
199
|
+
if body.message_deduplication_id:
|
|
200
|
+
params["MessageDeduplicationId"] = body.message_deduplication_id
|
|
201
|
+
if body.message_attributes:
|
|
202
|
+
params["MessageAttributes"] = {
|
|
203
|
+
key: {"DataType": value.data_type, "StringValue": value.string_value}
|
|
204
|
+
for key, value in body.message_attributes.items()
|
|
205
|
+
}
|
|
206
|
+
try:
|
|
207
|
+
response = sns.publish(**params)
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
raise HTTPException(status_code=400, detail=f"Failed to publish: {exc}")
|
|
210
|
+
return {"messageId": response.get("MessageId", "")}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Pydantic schemas for CloudWatch monitoring endpoints."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MetricDimension(BaseModel):
|
|
7
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
8
|
+
|
|
9
|
+
name: str
|
|
10
|
+
value: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MetricDataQuery(BaseModel):
|
|
14
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
15
|
+
|
|
16
|
+
id: str
|
|
17
|
+
namespace: str
|
|
18
|
+
metric_name: str = Field(alias="metricName")
|
|
19
|
+
dimensions: list[MetricDimension] = []
|
|
20
|
+
stat: str = "Average"
|
|
21
|
+
period: int = 60
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MetricDataRequest(BaseModel):
|
|
25
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
26
|
+
|
|
27
|
+
queries: list[MetricDataQuery]
|
|
28
|
+
start_minutes: int = Field(alias="startMinutes", default=60)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Pydantic schemas for SNS endpoints."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CreateTopicBody(BaseModel):
|
|
7
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
8
|
+
|
|
9
|
+
name: str
|
|
10
|
+
display_name: str | None = Field(alias="displayName", default=None)
|
|
11
|
+
fifo: bool = False
|
|
12
|
+
content_based_deduplication: bool = Field(alias="contentBasedDeduplication", default=False)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SubscribeBody(BaseModel):
|
|
16
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
17
|
+
|
|
18
|
+
protocol: str
|
|
19
|
+
endpoint: str
|
|
20
|
+
filter_policy: dict | None = Field(alias="filterPolicy", default=None)
|
|
21
|
+
raw_message_delivery: bool = Field(alias="rawMessageDelivery", default=False)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MessageAttributeValue(BaseModel):
|
|
25
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
26
|
+
|
|
27
|
+
data_type: str = Field(alias="dataType", default="String")
|
|
28
|
+
string_value: str = Field(alias="stringValue", default="")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PublishBody(BaseModel):
|
|
32
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
33
|
+
|
|
34
|
+
message: str
|
|
35
|
+
subject: str | None = None
|
|
36
|
+
message_group_id: str | None = Field(alias="messageGroupId", default=None)
|
|
37
|
+
message_deduplication_id: str | None = Field(alias="messageDeduplicationId", default=None)
|
|
38
|
+
message_attributes: dict[str, MessageAttributeValue] = Field(alias="messageAttributes", default_factory=dict)
|