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.
Files changed (137) hide show
  1. {stackport-0.3.9/stackport.egg-info → stackport-0.4.1}/PKG-INFO +6 -17
  2. {stackport-0.3.9 → stackport-0.4.1}/README.md +5 -16
  3. {stackport-0.3.9 → stackport-0.4.1}/backend/main.py +12 -2
  4. stackport-0.4.1/backend/routes/monitoring.py +131 -0
  5. stackport-0.4.1/backend/routes/sns.py +210 -0
  6. stackport-0.4.1/backend/schemas/monitoring.py +28 -0
  7. stackport-0.4.1/backend/schemas/sns.py +38 -0
  8. {stackport-0.3.9 → stackport-0.4.1}/backend/websocket.py +133 -0
  9. {stackport-0.3.9 → stackport-0.4.1}/pyproject.toml +1 -1
  10. {stackport-0.3.9 → stackport-0.4.1/stackport.egg-info}/PKG-INFO +6 -17
  11. {stackport-0.3.9 → stackport-0.4.1}/stackport.egg-info/SOURCES.txt +49 -6
  12. stackport-0.4.1/tests/test_logs_tail.py +104 -0
  13. stackport-0.4.1/tests/test_monitoring_routes.py +152 -0
  14. stackport-0.4.1/tests/test_sns_routes.py +197 -0
  15. stackport-0.4.1/ui/dist/assets/CloudscapeAbout-BFh7x4BC.js +1 -0
  16. stackport-0.4.1/ui/dist/assets/CloudscapeDashboard-DzRbBwTo.js +1 -0
  17. stackport-0.4.1/ui/dist/assets/CloudscapeResourceBrowser-DNa6P8a7.css +1 -0
  18. stackport-0.4.1/ui/dist/assets/CloudscapeResourceBrowser-DZE3EncJ.js +19 -0
  19. stackport-0.4.1/ui/dist/assets/CloudscapeSettings-BOzr9FgE.js +1 -0
  20. stackport-0.4.1/ui/dist/assets/CloudscapeShell-C1R1SIYZ.css +1 -0
  21. stackport-0.4.1/ui/dist/assets/CloudscapeShell-KCrV2gOE.js +201 -0
  22. stackport-0.4.1/ui/dist/assets/StateMachineGraph-wwDpbmDc.js +21 -0
  23. stackport-0.4.1/ui/dist/assets/index-BKtAPb3s.js +1 -0
  24. stackport-0.4.1/ui/dist/assets/index-BVHxAbst.js +1 -0
  25. stackport-0.4.1/ui/dist/assets/index-C_MN8WBt.js +1 -0
  26. stackport-0.4.1/ui/dist/assets/index-Cah5DqCf.css +1 -0
  27. stackport-0.4.1/ui/dist/assets/index-D7KAiUgR.js +52 -0
  28. stackport-0.4.1/ui/dist/assets/index-DE--A2EE.css +1 -0
  29. stackport-0.4.1/ui/dist/assets/index-DRZKHoZl.css +1 -0
  30. stackport-0.4.1/ui/dist/assets/index-DaXIyw62.css +1 -0
  31. stackport-0.4.1/ui/dist/assets/index-F7IxH4zH.js +1 -0
  32. stackport-0.4.1/ui/dist/assets/index-bpZOyX6N.js +1 -0
  33. stackport-0.4.1/ui/dist/assets/index-siFAyqi4.css +1 -0
  34. stackport-0.4.1/ui/dist/assets/utils-Cfmwk2UT.js +1 -0
  35. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/index.html +2 -2
  36. stackport-0.4.1/ui/dist/stackport-logo.svg +68 -0
  37. stackport-0.3.9/ui/dist/assets/StateMachineGraph-BGBIYpUV.js +0 -6
  38. stackport-0.3.9/ui/dist/assets/index-BZr5kWvs.css +0 -1
  39. stackport-0.3.9/ui/dist/assets/index-iwgw2OEV.js +0 -652
  40. {stackport-0.3.9 → stackport-0.4.1}/LICENSE +0 -0
  41. {stackport-0.3.9 → stackport-0.4.1}/MANIFEST.in +0 -0
  42. {stackport-0.3.9 → stackport-0.4.1}/backend/__init__.py +0 -0
  43. {stackport-0.3.9 → stackport-0.4.1}/backend/aws_client.py +0 -0
  44. {stackport-0.3.9 → stackport-0.4.1}/backend/cache.py +0 -0
  45. {stackport-0.3.9 → stackport-0.4.1}/backend/cli.py +0 -0
  46. {stackport-0.3.9 → stackport-0.4.1}/backend/config.py +0 -0
  47. {stackport-0.3.9 → stackport-0.4.1}/backend/endpoint_store.py +0 -0
  48. {stackport-0.3.9 → stackport-0.4.1}/backend/routes/__init__.py +0 -0
  49. {stackport-0.3.9 → stackport-0.4.1}/backend/routes/common.py +0 -0
  50. {stackport-0.3.9 → stackport-0.4.1}/backend/routes/dynamodb.py +0 -0
  51. {stackport-0.3.9 → stackport-0.4.1}/backend/routes/ec2.py +0 -0
  52. {stackport-0.3.9 → stackport-0.4.1}/backend/routes/endpoints.py +0 -0
  53. {stackport-0.3.9 → stackport-0.4.1}/backend/routes/iam.py +0 -0
  54. {stackport-0.3.9 → stackport-0.4.1}/backend/routes/lambda_svc.py +0 -0
  55. {stackport-0.3.9 → stackport-0.4.1}/backend/routes/logs.py +0 -0
  56. {stackport-0.3.9 → stackport-0.4.1}/backend/routes/rds.py +0 -0
  57. {stackport-0.3.9 → stackport-0.4.1}/backend/routes/resources.py +0 -0
  58. {stackport-0.3.9 → stackport-0.4.1}/backend/routes/s3.py +0 -0
  59. {stackport-0.3.9 → stackport-0.4.1}/backend/routes/secretsmanager.py +0 -0
  60. {stackport-0.3.9 → stackport-0.4.1}/backend/routes/sqs.py +0 -0
  61. {stackport-0.3.9 → stackport-0.4.1}/backend/routes/stats.py +0 -0
  62. {stackport-0.3.9 → stackport-0.4.1}/backend/routes/stepfunctions.py +0 -0
  63. {stackport-0.3.9 → stackport-0.4.1}/backend/routes/tags.py +0 -0
  64. {stackport-0.3.9 → stackport-0.4.1}/backend/schemas/__init__.py +0 -0
  65. {stackport-0.3.9 → stackport-0.4.1}/backend/schemas/dynamodb.py +0 -0
  66. {stackport-0.3.9 → stackport-0.4.1}/backend/schemas/endpoints.py +0 -0
  67. {stackport-0.3.9 → stackport-0.4.1}/backend/schemas/lambda_svc.py +0 -0
  68. {stackport-0.3.9 → stackport-0.4.1}/backend/schemas/logs.py +0 -0
  69. {stackport-0.3.9 → stackport-0.4.1}/backend/schemas/s3.py +0 -0
  70. {stackport-0.3.9 → stackport-0.4.1}/backend/schemas/secretsmanager.py +0 -0
  71. {stackport-0.3.9 → stackport-0.4.1}/backend/schemas/sqs.py +0 -0
  72. {stackport-0.3.9 → stackport-0.4.1}/backend/schemas/stepfunctions.py +0 -0
  73. {stackport-0.3.9 → stackport-0.4.1}/backend/schemas/tags.py +0 -0
  74. {stackport-0.3.9 → stackport-0.4.1}/setup.cfg +0 -0
  75. {stackport-0.3.9 → stackport-0.4.1}/stackport.egg-info/dependency_links.txt +0 -0
  76. {stackport-0.3.9 → stackport-0.4.1}/stackport.egg-info/entry_points.txt +0 -0
  77. {stackport-0.3.9 → stackport-0.4.1}/stackport.egg-info/requires.txt +0 -0
  78. {stackport-0.3.9 → stackport-0.4.1}/stackport.egg-info/top_level.txt +0 -0
  79. {stackport-0.3.9 → stackport-0.4.1}/tests/test_cache.py +0 -0
  80. {stackport-0.3.9 → stackport-0.4.1}/tests/test_cli.py +0 -0
  81. {stackport-0.3.9 → stackport-0.4.1}/tests/test_client.py +0 -0
  82. {stackport-0.3.9 → stackport-0.4.1}/tests/test_config.py +0 -0
  83. {stackport-0.3.9 → stackport-0.4.1}/tests/test_dynamodb_routes.py +0 -0
  84. {stackport-0.3.9 → stackport-0.4.1}/tests/test_ec2_routes.py +0 -0
  85. {stackport-0.3.9 → stackport-0.4.1}/tests/test_endpoint_store.py +0 -0
  86. {stackport-0.3.9 → stackport-0.4.1}/tests/test_endpoints.py +0 -0
  87. {stackport-0.3.9 → stackport-0.4.1}/tests/test_iam_routes.py +0 -0
  88. {stackport-0.3.9 → stackport-0.4.1}/tests/test_lambda_routes.py +0 -0
  89. {stackport-0.3.9 → stackport-0.4.1}/tests/test_logs_routes.py +0 -0
  90. {stackport-0.3.9 → stackport-0.4.1}/tests/test_multi_endpoint.py +0 -0
  91. {stackport-0.3.9 → stackport-0.4.1}/tests/test_rds_routes.py +0 -0
  92. {stackport-0.3.9 → stackport-0.4.1}/tests/test_readonly_middleware.py +0 -0
  93. {stackport-0.3.9 → stackport-0.4.1}/tests/test_registries.py +0 -0
  94. {stackport-0.3.9 → stackport-0.4.1}/tests/test_routes.py +0 -0
  95. {stackport-0.3.9 → stackport-0.4.1}/tests/test_s3_routes.py +0 -0
  96. {stackport-0.3.9 → stackport-0.4.1}/tests/test_s3_upload_limit_env.py +0 -0
  97. {stackport-0.3.9 → stackport-0.4.1}/tests/test_secretsmanager_routes.py +0 -0
  98. {stackport-0.3.9 → stackport-0.4.1}/tests/test_sqs_routes.py +0 -0
  99. {stackport-0.3.9 → stackport-0.4.1}/tests/test_tags_routes.py +0 -0
  100. {stackport-0.3.9 → stackport-0.4.1}/tests/test_websocket.py +0 -0
  101. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/assets/stepfunctions-graph-B6nvU4OB.js +0 -0
  102. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/acm.svg +0 -0
  103. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/apigateway.svg +0 -0
  104. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/appsync.svg +0 -0
  105. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/athena.svg +0 -0
  106. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/cloudformation.svg +0 -0
  107. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/cloudfront.svg +0 -0
  108. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/cognito-idp.svg +0 -0
  109. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/dynamodb.svg +0 -0
  110. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/ec2.svg +0 -0
  111. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/ecr.svg +0 -0
  112. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/ecs.svg +0 -0
  113. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/elasticache.svg +0 -0
  114. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
  115. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
  116. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
  117. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/events.svg +0 -0
  118. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/firehose.svg +0 -0
  119. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/glue.svg +0 -0
  120. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/iam.svg +0 -0
  121. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/kinesis.svg +0 -0
  122. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/kms.svg +0 -0
  123. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/lambda.svg +0 -0
  124. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/logs.svg +0 -0
  125. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/monitoring.svg +0 -0
  126. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/rds.svg +0 -0
  127. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/route53.svg +0 -0
  128. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/s3.svg +0 -0
  129. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/secretsmanager.svg +0 -0
  130. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/ses.svg +0 -0
  131. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/sns.svg +0 -0
  132. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/sqs.svg +0 -0
  133. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/ssm.svg +0 -0
  134. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/stepfunctions.svg +0 -0
  135. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/aws-icons/wafv2.svg +0 -0
  136. {stackport-0.3.9 → stackport-0.4.1}/ui/dist/favicon.png +0 -0
  137. {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.9
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
- ![StackPort Dashboard](https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docs/images/dashboard.jpeg?v=1.0)
58
+ ![StackPort Dashboard](https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docs/images/dashboard.jpeg?v=2.1)
59
59
 
60
60
  **Lambda Browser** — Function list with config, aliases, versions, event sources, and invocation panel
61
- ![Lambda Resources](https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docs/images/lambda.jpeg?v=1.0)
61
+ ![Lambda Resources](https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docs/images/lambda.jpeg?v=2.1)
62
62
 
63
63
  **S3 Browser** — File browser with upload, download, folder navigation, and tagging
64
- ![S3 Browser](https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docs/images/s3.jpeg?v=1.0)
64
+ ![S3 Browser](https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docs/images/s3.jpeg?v=2.1)
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/ Dashboard, ResourceBrowser, About
329
+ pages/ CloudscapeDashboard, CloudscapeResourceBrowser, CloudscapeSettings, CloudscapeAbout
330
330
  components/
331
- service-views/ S3Browser, DynamoDBBrowser, LambdaBrowser, ...
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
- ![StackPort Dashboard](https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docs/images/dashboard.jpeg?v=1.0)
22
+ ![StackPort Dashboard](https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docs/images/dashboard.jpeg?v=2.1)
23
23
 
24
24
  **Lambda Browser** — Function list with config, aliases, versions, event sources, and invocation panel
25
- ![Lambda Resources](https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docs/images/lambda.jpeg?v=1.0)
25
+ ![Lambda Resources](https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docs/images/lambda.jpeg?v=2.1)
26
26
 
27
27
  **S3 Browser** — File browser with upload, download, folder navigation, and tagging
28
- ![S3 Browser](https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docs/images/s3.jpeg?v=1.0)
28
+ ![S3 Browser](https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docs/images/s3.jpeg?v=2.1)
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/ Dashboard, ResourceBrowser, About
293
+ pages/ CloudscapeDashboard, CloudscapeResourceBrowser, CloudscapeSettings, CloudscapeAbout
294
294
  components/
295
- service-views/ S3Browser, DynamoDBBrowser, LambdaBrowser, ...
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)