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.
- {stackport-0.4.0/stackport.egg-info → stackport-0.4.1}/PKG-INFO +1 -1
- {stackport-0.4.0 → 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.4.0 → stackport-0.4.1}/backend/websocket.py +133 -0
- {stackport-0.4.0 → stackport-0.4.1}/pyproject.toml +1 -1
- {stackport-0.4.0 → stackport-0.4.1/stackport.egg-info}/PKG-INFO +1 -1
- {stackport-0.4.0 → stackport-0.4.1}/stackport.egg-info/SOURCES.txt +35 -28
- 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.0/ui/dist/assets/CloudscapeAbout-BtS8xfBQ.js → stackport-0.4.1/ui/dist/assets/CloudscapeAbout-BFh7x4BC.js +1 -1
- stackport-0.4.0/ui/dist/assets/CloudscapeDashboard-XTN3dZMK.js → stackport-0.4.1/ui/dist/assets/CloudscapeDashboard-DzRbBwTo.js +1 -1
- stackport-0.4.0/ui/dist/assets/CloudscapeResourceBrowser-BW5xV4W2.css → stackport-0.4.1/ui/dist/assets/CloudscapeResourceBrowser-DNa6P8a7.css +1 -1
- stackport-0.4.1/ui/dist/assets/CloudscapeResourceBrowser-DZE3EncJ.js +19 -0
- stackport-0.4.0/ui/dist/assets/CloudscapeSettings-BRB1cWqz.js → stackport-0.4.1/ui/dist/assets/CloudscapeSettings-BOzr9FgE.js +1 -1
- stackport-0.4.1/ui/dist/assets/CloudscapeShell-KCrV2gOE.js +201 -0
- stackport-0.4.0/ui/dist/assets/StateMachineGraph-BOEoyAZx.js → stackport-0.4.1/ui/dist/assets/StateMachineGraph-wwDpbmDc.js +1 -1
- stackport-0.4.0/ui/dist/assets/index-BqhwBjJc.js → stackport-0.4.1/ui/dist/assets/index-BKtAPb3s.js +1 -1
- stackport-0.4.1/ui/dist/assets/index-BVHxAbst.js +1 -0
- stackport-0.4.0/ui/dist/assets/index-C7soqJ2K.js → stackport-0.4.1/ui/dist/assets/index-C_MN8WBt.js +1 -1
- stackport-0.4.0/ui/dist/assets/index-DOVP6iM8.js → stackport-0.4.1/ui/dist/assets/index-D7KAiUgR.js +12 -12
- stackport-0.4.1/ui/dist/assets/index-F7IxH4zH.js +1 -0
- stackport-0.4.0/ui/dist/assets/index-Dj8ImrkQ.js → stackport-0.4.1/ui/dist/assets/index-bpZOyX6N.js +1 -1
- stackport-0.4.0/ui/dist/assets/index-BjtNHAFy.css → stackport-0.4.1/ui/dist/assets/index-siFAyqi4.css +1 -1
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/index.html +2 -2
- stackport-0.4.0/ui/dist/assets/CloudscapeResourceBrowser-CcOhO1RI.js +0 -16
- stackport-0.4.0/ui/dist/assets/CloudscapeShell-DojdksJr.js +0 -200
- stackport-0.4.0/ui/dist/assets/index-CCLJtnX8.js +0 -1
- stackport-0.4.0/ui/dist/assets/index-lN483PN7.js +0 -1
- {stackport-0.4.0 → stackport-0.4.1}/LICENSE +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/MANIFEST.in +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/README.md +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/__init__.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/aws_client.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/cache.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/cli.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/config.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/endpoint_store.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/routes/__init__.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/routes/common.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/routes/dynamodb.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/routes/ec2.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/routes/endpoints.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/routes/iam.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/routes/lambda_svc.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/routes/logs.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/routes/rds.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/routes/resources.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/routes/s3.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/routes/secretsmanager.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/routes/sqs.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/routes/stats.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/routes/stepfunctions.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/routes/tags.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/schemas/__init__.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/schemas/dynamodb.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/schemas/endpoints.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/schemas/lambda_svc.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/schemas/logs.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/schemas/s3.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/schemas/secretsmanager.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/schemas/sqs.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/schemas/stepfunctions.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/backend/schemas/tags.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/setup.cfg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/stackport.egg-info/dependency_links.txt +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/stackport.egg-info/entry_points.txt +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/stackport.egg-info/requires.txt +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/stackport.egg-info/top_level.txt +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/tests/test_cache.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/tests/test_cli.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/tests/test_client.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/tests/test_config.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/tests/test_dynamodb_routes.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/tests/test_ec2_routes.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/tests/test_endpoint_store.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/tests/test_endpoints.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/tests/test_iam_routes.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/tests/test_lambda_routes.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/tests/test_logs_routes.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/tests/test_multi_endpoint.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/tests/test_rds_routes.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/tests/test_readonly_middleware.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/tests/test_registries.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/tests/test_routes.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/tests/test_s3_routes.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/tests/test_s3_upload_limit_env.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/tests/test_secretsmanager_routes.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/tests/test_sqs_routes.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/tests/test_tags_routes.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/tests/test_websocket.py +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/assets/CloudscapeShell-C1R1SIYZ.css +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/assets/index-Cah5DqCf.css +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/assets/index-DE--A2EE.css +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/assets/index-DRZKHoZl.css +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/assets/index-DaXIyw62.css +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/assets/stepfunctions-graph-B6nvU4OB.js +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/assets/utils-Cfmwk2UT.js +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/acm.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/apigateway.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/appsync.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/athena.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/cloudformation.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/cloudfront.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/cognito-idp.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/dynamodb.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/ec2.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/ecr.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/ecs.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/elasticache.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/events.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/firehose.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/glue.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/iam.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/kinesis.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/kms.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/lambda.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/logs.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/monitoring.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/rds.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/route53.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/s3.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/secretsmanager.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/ses.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/sns.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/sqs.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/ssm.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/stepfunctions.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/aws-icons/wafv2.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/favicon.png +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/favicon.svg +0 -0
- {stackport-0.4.0 → stackport-0.4.1}/ui/dist/stackport-logo.svg +0 -0
|
@@ -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"})
|