stackport 0.4.0__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 (138) hide show
  1. {stackport-0.4.0/stackport.egg-info → stackport-0.4.1}/PKG-INFO +1 -1
  2. {stackport-0.4.0 → stackport-0.4.1}/backend/main.py +12 -2
  3. stackport-0.4.1/backend/routes/monitoring.py +131 -0
  4. stackport-0.4.1/backend/routes/sns.py +210 -0
  5. stackport-0.4.1/backend/schemas/monitoring.py +28 -0
  6. stackport-0.4.1/backend/schemas/sns.py +38 -0
  7. {stackport-0.4.0 → stackport-0.4.1}/backend/websocket.py +133 -0
  8. {stackport-0.4.0 → stackport-0.4.1}/pyproject.toml +1 -1
  9. {stackport-0.4.0 → stackport-0.4.1/stackport.egg-info}/PKG-INFO +1 -1
  10. {stackport-0.4.0 → stackport-0.4.1}/stackport.egg-info/SOURCES.txt +35 -28
  11. stackport-0.4.1/tests/test_logs_tail.py +104 -0
  12. stackport-0.4.1/tests/test_monitoring_routes.py +152 -0
  13. stackport-0.4.1/tests/test_sns_routes.py +197 -0
  14. stackport-0.4.0/ui/dist/assets/CloudscapeAbout-BtS8xfBQ.js → stackport-0.4.1/ui/dist/assets/CloudscapeAbout-BFh7x4BC.js +1 -1
  15. stackport-0.4.0/ui/dist/assets/CloudscapeDashboard-XTN3dZMK.js → stackport-0.4.1/ui/dist/assets/CloudscapeDashboard-DzRbBwTo.js +1 -1
  16. stackport-0.4.0/ui/dist/assets/CloudscapeResourceBrowser-BW5xV4W2.css → stackport-0.4.1/ui/dist/assets/CloudscapeResourceBrowser-DNa6P8a7.css +1 -1
  17. stackport-0.4.1/ui/dist/assets/CloudscapeResourceBrowser-DZE3EncJ.js +19 -0
  18. stackport-0.4.0/ui/dist/assets/CloudscapeSettings-BRB1cWqz.js → stackport-0.4.1/ui/dist/assets/CloudscapeSettings-BOzr9FgE.js +1 -1
  19. stackport-0.4.1/ui/dist/assets/CloudscapeShell-KCrV2gOE.js +201 -0
  20. stackport-0.4.0/ui/dist/assets/StateMachineGraph-BOEoyAZx.js → stackport-0.4.1/ui/dist/assets/StateMachineGraph-wwDpbmDc.js +1 -1
  21. stackport-0.4.0/ui/dist/assets/index-BqhwBjJc.js → stackport-0.4.1/ui/dist/assets/index-BKtAPb3s.js +1 -1
  22. stackport-0.4.1/ui/dist/assets/index-BVHxAbst.js +1 -0
  23. stackport-0.4.0/ui/dist/assets/index-C7soqJ2K.js → stackport-0.4.1/ui/dist/assets/index-C_MN8WBt.js +1 -1
  24. stackport-0.4.0/ui/dist/assets/index-DOVP6iM8.js → stackport-0.4.1/ui/dist/assets/index-D7KAiUgR.js +12 -12
  25. stackport-0.4.1/ui/dist/assets/index-F7IxH4zH.js +1 -0
  26. stackport-0.4.0/ui/dist/assets/index-Dj8ImrkQ.js → stackport-0.4.1/ui/dist/assets/index-bpZOyX6N.js +1 -1
  27. stackport-0.4.0/ui/dist/assets/index-BjtNHAFy.css → stackport-0.4.1/ui/dist/assets/index-siFAyqi4.css +1 -1
  28. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/index.html +2 -2
  29. stackport-0.4.0/ui/dist/assets/CloudscapeResourceBrowser-CcOhO1RI.js +0 -16
  30. stackport-0.4.0/ui/dist/assets/CloudscapeShell-DojdksJr.js +0 -200
  31. stackport-0.4.0/ui/dist/assets/index-CCLJtnX8.js +0 -1
  32. stackport-0.4.0/ui/dist/assets/index-lN483PN7.js +0 -1
  33. {stackport-0.4.0 → stackport-0.4.1}/LICENSE +0 -0
  34. {stackport-0.4.0 → stackport-0.4.1}/MANIFEST.in +0 -0
  35. {stackport-0.4.0 → stackport-0.4.1}/README.md +0 -0
  36. {stackport-0.4.0 → stackport-0.4.1}/backend/__init__.py +0 -0
  37. {stackport-0.4.0 → stackport-0.4.1}/backend/aws_client.py +0 -0
  38. {stackport-0.4.0 → stackport-0.4.1}/backend/cache.py +0 -0
  39. {stackport-0.4.0 → stackport-0.4.1}/backend/cli.py +0 -0
  40. {stackport-0.4.0 → stackport-0.4.1}/backend/config.py +0 -0
  41. {stackport-0.4.0 → stackport-0.4.1}/backend/endpoint_store.py +0 -0
  42. {stackport-0.4.0 → stackport-0.4.1}/backend/routes/__init__.py +0 -0
  43. {stackport-0.4.0 → stackport-0.4.1}/backend/routes/common.py +0 -0
  44. {stackport-0.4.0 → stackport-0.4.1}/backend/routes/dynamodb.py +0 -0
  45. {stackport-0.4.0 → stackport-0.4.1}/backend/routes/ec2.py +0 -0
  46. {stackport-0.4.0 → stackport-0.4.1}/backend/routes/endpoints.py +0 -0
  47. {stackport-0.4.0 → stackport-0.4.1}/backend/routes/iam.py +0 -0
  48. {stackport-0.4.0 → stackport-0.4.1}/backend/routes/lambda_svc.py +0 -0
  49. {stackport-0.4.0 → stackport-0.4.1}/backend/routes/logs.py +0 -0
  50. {stackport-0.4.0 → stackport-0.4.1}/backend/routes/rds.py +0 -0
  51. {stackport-0.4.0 → stackport-0.4.1}/backend/routes/resources.py +0 -0
  52. {stackport-0.4.0 → stackport-0.4.1}/backend/routes/s3.py +0 -0
  53. {stackport-0.4.0 → stackport-0.4.1}/backend/routes/secretsmanager.py +0 -0
  54. {stackport-0.4.0 → stackport-0.4.1}/backend/routes/sqs.py +0 -0
  55. {stackport-0.4.0 → stackport-0.4.1}/backend/routes/stats.py +0 -0
  56. {stackport-0.4.0 → stackport-0.4.1}/backend/routes/stepfunctions.py +0 -0
  57. {stackport-0.4.0 → stackport-0.4.1}/backend/routes/tags.py +0 -0
  58. {stackport-0.4.0 → stackport-0.4.1}/backend/schemas/__init__.py +0 -0
  59. {stackport-0.4.0 → stackport-0.4.1}/backend/schemas/dynamodb.py +0 -0
  60. {stackport-0.4.0 → stackport-0.4.1}/backend/schemas/endpoints.py +0 -0
  61. {stackport-0.4.0 → stackport-0.4.1}/backend/schemas/lambda_svc.py +0 -0
  62. {stackport-0.4.0 → stackport-0.4.1}/backend/schemas/logs.py +0 -0
  63. {stackport-0.4.0 → stackport-0.4.1}/backend/schemas/s3.py +0 -0
  64. {stackport-0.4.0 → stackport-0.4.1}/backend/schemas/secretsmanager.py +0 -0
  65. {stackport-0.4.0 → stackport-0.4.1}/backend/schemas/sqs.py +0 -0
  66. {stackport-0.4.0 → stackport-0.4.1}/backend/schemas/stepfunctions.py +0 -0
  67. {stackport-0.4.0 → stackport-0.4.1}/backend/schemas/tags.py +0 -0
  68. {stackport-0.4.0 → stackport-0.4.1}/setup.cfg +0 -0
  69. {stackport-0.4.0 → stackport-0.4.1}/stackport.egg-info/dependency_links.txt +0 -0
  70. {stackport-0.4.0 → stackport-0.4.1}/stackport.egg-info/entry_points.txt +0 -0
  71. {stackport-0.4.0 → stackport-0.4.1}/stackport.egg-info/requires.txt +0 -0
  72. {stackport-0.4.0 → stackport-0.4.1}/stackport.egg-info/top_level.txt +0 -0
  73. {stackport-0.4.0 → stackport-0.4.1}/tests/test_cache.py +0 -0
  74. {stackport-0.4.0 → stackport-0.4.1}/tests/test_cli.py +0 -0
  75. {stackport-0.4.0 → stackport-0.4.1}/tests/test_client.py +0 -0
  76. {stackport-0.4.0 → stackport-0.4.1}/tests/test_config.py +0 -0
  77. {stackport-0.4.0 → stackport-0.4.1}/tests/test_dynamodb_routes.py +0 -0
  78. {stackport-0.4.0 → stackport-0.4.1}/tests/test_ec2_routes.py +0 -0
  79. {stackport-0.4.0 → stackport-0.4.1}/tests/test_endpoint_store.py +0 -0
  80. {stackport-0.4.0 → stackport-0.4.1}/tests/test_endpoints.py +0 -0
  81. {stackport-0.4.0 → stackport-0.4.1}/tests/test_iam_routes.py +0 -0
  82. {stackport-0.4.0 → stackport-0.4.1}/tests/test_lambda_routes.py +0 -0
  83. {stackport-0.4.0 → stackport-0.4.1}/tests/test_logs_routes.py +0 -0
  84. {stackport-0.4.0 → stackport-0.4.1}/tests/test_multi_endpoint.py +0 -0
  85. {stackport-0.4.0 → stackport-0.4.1}/tests/test_rds_routes.py +0 -0
  86. {stackport-0.4.0 → stackport-0.4.1}/tests/test_readonly_middleware.py +0 -0
  87. {stackport-0.4.0 → stackport-0.4.1}/tests/test_registries.py +0 -0
  88. {stackport-0.4.0 → stackport-0.4.1}/tests/test_routes.py +0 -0
  89. {stackport-0.4.0 → stackport-0.4.1}/tests/test_s3_routes.py +0 -0
  90. {stackport-0.4.0 → stackport-0.4.1}/tests/test_s3_upload_limit_env.py +0 -0
  91. {stackport-0.4.0 → stackport-0.4.1}/tests/test_secretsmanager_routes.py +0 -0
  92. {stackport-0.4.0 → stackport-0.4.1}/tests/test_sqs_routes.py +0 -0
  93. {stackport-0.4.0 → stackport-0.4.1}/tests/test_tags_routes.py +0 -0
  94. {stackport-0.4.0 → stackport-0.4.1}/tests/test_websocket.py +0 -0
  95. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/assets/CloudscapeShell-C1R1SIYZ.css +0 -0
  96. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/assets/index-Cah5DqCf.css +0 -0
  97. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/assets/index-DE--A2EE.css +0 -0
  98. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/assets/index-DRZKHoZl.css +0 -0
  99. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/assets/index-DaXIyw62.css +0 -0
  100. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/assets/stepfunctions-graph-B6nvU4OB.js +0 -0
  101. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/assets/utils-Cfmwk2UT.js +0 -0
  102. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/acm.svg +0 -0
  103. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/apigateway.svg +0 -0
  104. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/appsync.svg +0 -0
  105. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/athena.svg +0 -0
  106. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/cloudformation.svg +0 -0
  107. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/cloudfront.svg +0 -0
  108. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/cognito-idp.svg +0 -0
  109. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/dynamodb.svg +0 -0
  110. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/ec2.svg +0 -0
  111. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/ecr.svg +0 -0
  112. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/ecs.svg +0 -0
  113. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/elasticache.svg +0 -0
  114. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
  115. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
  116. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
  117. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/events.svg +0 -0
  118. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/firehose.svg +0 -0
  119. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/glue.svg +0 -0
  120. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/iam.svg +0 -0
  121. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/kinesis.svg +0 -0
  122. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/kms.svg +0 -0
  123. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/lambda.svg +0 -0
  124. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/logs.svg +0 -0
  125. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/monitoring.svg +0 -0
  126. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/rds.svg +0 -0
  127. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/route53.svg +0 -0
  128. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/s3.svg +0 -0
  129. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/secretsmanager.svg +0 -0
  130. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/ses.svg +0 -0
  131. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/sns.svg +0 -0
  132. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/sqs.svg +0 -0
  133. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/ssm.svg +0 -0
  134. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/stepfunctions.svg +0 -0
  135. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/wafv2.svg +0 -0
  136. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/favicon.png +0 -0
  137. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/favicon.svg +0 -0
  138. {stackport-0.4.0 → stackport-0.4.1}/ui/dist/stackport-logo.svg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackport
3
- Version: 0.4.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
@@ -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)
@@ -8,11 +8,15 @@ import time
8
8
 
9
9
  from fastapi import WebSocket, WebSocketDisconnect
10
10
 
11
+ from backend.aws_client import get_client
11
12
  from backend.config import endpoint_store, STACKPORT_SERVICES
13
+ from backend.routes.logs import _epoch_millis_to_iso
12
14
  from backend.routes.stats import _probe_service, _start_time
13
15
 
14
16
  logger = logging.getLogger(__name__)
15
17
 
18
+ TAIL_POLL_SECONDS = 1.0
19
+
16
20
 
17
21
  class ConnectionManager:
18
22
  """Manages active WebSocket connections with per-endpoint subscriptions."""
@@ -146,6 +150,135 @@ async def websocket_endpoint(websocket: WebSocket):
146
150
  manager.disconnect(websocket)
147
151
 
148
152
 
153
+ def _client_kwargs_for(endpoint_name_or_url: str | None) -> dict:
154
+ """Build get_client kwargs (endpoint URL, region, auth) for an endpoint name or URL."""
155
+ url = endpoint_store.resolve(endpoint_name_or_url)
156
+ entry = _find_entry_for_url(url)
157
+ return {
158
+ "endpoint_url": url,
159
+ "region": entry.get("region") if entry else None,
160
+ "auth_type": entry.get("auth_type", "default") if entry else "default",
161
+ "auth_profile": entry.get("auth_profile") if entry else None,
162
+ "auth_access_key_id": entry.get("auth_access_key_id") if entry else None,
163
+ "auth_secret_access_key": entry.get("auth_secret_access_key") if entry else None,
164
+ }
165
+
166
+
167
+ def _fetch_new_events(
168
+ group: str,
169
+ stream: str,
170
+ since_millis: int,
171
+ filter_pattern: str,
172
+ client_kwargs: dict,
173
+ ) -> list[dict]:
174
+ """Fetch log events newer than since_millis; errors degrade to an empty batch."""
175
+ try:
176
+ logs = get_client("logs", **client_kwargs)
177
+ if filter_pattern:
178
+ params: dict = {
179
+ "logGroupName": group,
180
+ "logStreamNames": [stream],
181
+ "limit": 500,
182
+ "filterPattern": filter_pattern,
183
+ }
184
+ if since_millis > 0:
185
+ params["startTime"] = since_millis
186
+ raw = logs.filter_log_events(**params).get("events", [])
187
+ else:
188
+ params = {
189
+ "logGroupName": group,
190
+ "logStreamName": stream,
191
+ "limit": 500,
192
+ "startFromHead": True,
193
+ }
194
+ if since_millis > 0:
195
+ params["startTime"] = since_millis
196
+ raw = logs.get_log_events(**params).get("events", [])
197
+ except Exception:
198
+ logger.debug("Tail fetch failed for %s/%s", group, stream, exc_info=True)
199
+ return []
200
+
201
+ return [
202
+ {
203
+ "timestamp": _epoch_millis_to_iso(event["timestamp"]),
204
+ "timestamp_millis": event["timestamp"],
205
+ "message": event["message"],
206
+ "ingestion_time": _epoch_millis_to_iso(event.get("ingestionTime")),
207
+ "event_id": event.get("eventId", ""),
208
+ }
209
+ for event in raw
210
+ ]
211
+
212
+
213
+ async def logs_tail_endpoint(websocket: WebSocket):
214
+ """Tail a log stream over WebSocket.
215
+
216
+ The client opens the socket and sends one configuration message:
217
+ {"type": "tail", "group": ..., "stream": ..., "endpoint": ...,
218
+ "filterPattern": ..., "since": <epoch millis of the newest event it has>}
219
+
220
+ The server then polls the emulator and pushes {"type": "events", "data": {"events": [...]}}
221
+ batches until the client sends {"type": "stop"} or disconnects.
222
+ """
223
+ await websocket.accept()
224
+ try:
225
+ raw = await websocket.receive_text()
226
+ try:
227
+ cfg = json.loads(raw)
228
+ except json.JSONDecodeError:
229
+ cfg = {}
230
+ group = cfg.get("group")
231
+ stream = cfg.get("stream")
232
+ if cfg.get("type") != "tail" or not group or not stream:
233
+ await websocket.send_text(json.dumps({"type": "error", "message": "expected a tail message with group and stream"}))
234
+ await websocket.close()
235
+ return
236
+
237
+ filter_pattern = cfg.get("filterPattern") or ""
238
+ since = int(cfg.get("since") or time.time() * 1000)
239
+ client_kwargs = _client_kwargs_for(cfg.get("endpoint"))
240
+
241
+ stop = asyncio.Event()
242
+
243
+ async def reader():
244
+ try:
245
+ while True:
246
+ msg = await websocket.receive_text()
247
+ try:
248
+ if json.loads(msg).get("type") == "stop":
249
+ break
250
+ except json.JSONDecodeError:
251
+ pass
252
+ except WebSocketDisconnect:
253
+ pass
254
+ finally:
255
+ stop.set()
256
+
257
+ reader_task = asyncio.create_task(reader())
258
+ loop = asyncio.get_event_loop()
259
+ try:
260
+ while not stop.is_set():
261
+ events = await loop.run_in_executor(
262
+ None,
263
+ functools.partial(_fetch_new_events, group, stream, since, filter_pattern, client_kwargs),
264
+ )
265
+ if stop.is_set():
266
+ break
267
+ if events:
268
+ since = max(e["timestamp_millis"] for e in events) + 1
269
+ await websocket.send_text(json.dumps({"type": "events", "data": {"events": events}}))
270
+ try:
271
+ await asyncio.wait_for(stop.wait(), timeout=TAIL_POLL_SECONDS)
272
+ except asyncio.TimeoutError:
273
+ pass
274
+ finally:
275
+ reader_task.cancel()
276
+ except WebSocketDisconnect:
277
+ pass
278
+ except Exception:
279
+ logger.debug("logs tail connection ended with error", exc_info=True)
280
+
281
+
149
282
  async def broadcast_endpoints_changed():
150
283
  """Broadcast endpoints_changed event to all connected clients."""
151
284
  await manager.broadcast_to_all({"type": "endpoints_changed"})
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackport"
3
- version = "0.4.0"
3
+ version = "0.4.1"
4
4
  description = "Universal AWS resource browser for local emulators"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackport
3
- Version: 0.4.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