stackport 0.3.1__tar.gz → 0.3.3__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 (108) hide show
  1. {stackport-0.3.1/stackport.egg-info → stackport-0.3.3}/PKG-INFO +1 -1
  2. {stackport-0.3.1 → stackport-0.3.3}/backend/main.py +4 -2
  3. {stackport-0.3.1 → stackport-0.3.3}/backend/routes/lambda_svc.py +52 -0
  4. stackport-0.3.3/backend/routes/stepfunctions.py +181 -0
  5. stackport-0.3.3/backend/schemas/lambda_svc.py +23 -0
  6. stackport-0.3.3/backend/schemas/stepfunctions.py +21 -0
  7. {stackport-0.3.1 → stackport-0.3.3}/pyproject.toml +1 -1
  8. {stackport-0.3.1 → stackport-0.3.3/stackport.egg-info}/PKG-INFO +1 -1
  9. {stackport-0.3.1 → stackport-0.3.3}/stackport.egg-info/SOURCES.txt +11 -4
  10. {stackport-0.3.1 → stackport-0.3.3}/tests/test_lambda_routes.py +181 -0
  11. stackport-0.3.3/ui/dist/assets/StateMachineGraph-DKisycYa.js +6 -0
  12. stackport-0.3.3/ui/dist/assets/index-CCOvZYbr.css +1 -0
  13. stackport-0.3.3/ui/dist/assets/index-XBYym2zo.js +652 -0
  14. stackport-0.3.3/ui/dist/assets/stepfunctions-graph-B6nvU4OB.js +1 -0
  15. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/index.html +2 -2
  16. stackport-0.3.1/ui/dist/assets/index-CAZXHF4B.js +0 -639
  17. stackport-0.3.1/ui/dist/assets/index-DLd-xBZq.css +0 -1
  18. {stackport-0.3.1 → stackport-0.3.3}/LICENSE +0 -0
  19. {stackport-0.3.1 → stackport-0.3.3}/MANIFEST.in +0 -0
  20. {stackport-0.3.1 → stackport-0.3.3}/README.md +0 -0
  21. {stackport-0.3.1 → stackport-0.3.3}/backend/__init__.py +0 -0
  22. {stackport-0.3.1 → stackport-0.3.3}/backend/aws_client.py +0 -0
  23. {stackport-0.3.1 → stackport-0.3.3}/backend/cache.py +0 -0
  24. {stackport-0.3.1 → stackport-0.3.3}/backend/cli.py +0 -0
  25. {stackport-0.3.1 → stackport-0.3.3}/backend/config.py +0 -0
  26. {stackport-0.3.1 → stackport-0.3.3}/backend/endpoint_store.py +0 -0
  27. {stackport-0.3.1 → stackport-0.3.3}/backend/routes/__init__.py +0 -0
  28. {stackport-0.3.1 → stackport-0.3.3}/backend/routes/common.py +0 -0
  29. {stackport-0.3.1 → stackport-0.3.3}/backend/routes/dynamodb.py +0 -0
  30. {stackport-0.3.1 → stackport-0.3.3}/backend/routes/ec2.py +0 -0
  31. {stackport-0.3.1 → stackport-0.3.3}/backend/routes/endpoints.py +0 -0
  32. {stackport-0.3.1 → stackport-0.3.3}/backend/routes/iam.py +0 -0
  33. {stackport-0.3.1 → stackport-0.3.3}/backend/routes/logs.py +0 -0
  34. {stackport-0.3.1 → stackport-0.3.3}/backend/routes/resources.py +0 -0
  35. {stackport-0.3.1 → stackport-0.3.3}/backend/routes/s3.py +0 -0
  36. {stackport-0.3.1 → stackport-0.3.3}/backend/routes/secretsmanager.py +0 -0
  37. {stackport-0.3.1 → stackport-0.3.3}/backend/routes/sqs.py +0 -0
  38. {stackport-0.3.1 → stackport-0.3.3}/backend/routes/stats.py +0 -0
  39. {stackport-0.3.1 → stackport-0.3.3}/backend/routes/tags.py +0 -0
  40. {stackport-0.3.1 → stackport-0.3.3}/backend/schemas/__init__.py +0 -0
  41. {stackport-0.3.1 → stackport-0.3.3}/backend/schemas/dynamodb.py +0 -0
  42. {stackport-0.3.1 → stackport-0.3.3}/backend/schemas/endpoints.py +0 -0
  43. {stackport-0.3.1 → stackport-0.3.3}/backend/schemas/s3.py +0 -0
  44. {stackport-0.3.1 → stackport-0.3.3}/backend/schemas/secretsmanager.py +0 -0
  45. {stackport-0.3.1 → stackport-0.3.3}/backend/schemas/sqs.py +0 -0
  46. {stackport-0.3.1 → stackport-0.3.3}/backend/schemas/tags.py +0 -0
  47. {stackport-0.3.1 → stackport-0.3.3}/backend/websocket.py +0 -0
  48. {stackport-0.3.1 → stackport-0.3.3}/setup.cfg +0 -0
  49. {stackport-0.3.1 → stackport-0.3.3}/stackport.egg-info/dependency_links.txt +0 -0
  50. {stackport-0.3.1 → stackport-0.3.3}/stackport.egg-info/entry_points.txt +0 -0
  51. {stackport-0.3.1 → stackport-0.3.3}/stackport.egg-info/requires.txt +0 -0
  52. {stackport-0.3.1 → stackport-0.3.3}/stackport.egg-info/top_level.txt +0 -0
  53. {stackport-0.3.1 → stackport-0.3.3}/tests/test_cache.py +0 -0
  54. {stackport-0.3.1 → stackport-0.3.3}/tests/test_cli.py +0 -0
  55. {stackport-0.3.1 → stackport-0.3.3}/tests/test_client.py +0 -0
  56. {stackport-0.3.1 → stackport-0.3.3}/tests/test_config.py +0 -0
  57. {stackport-0.3.1 → stackport-0.3.3}/tests/test_dynamodb_routes.py +0 -0
  58. {stackport-0.3.1 → stackport-0.3.3}/tests/test_ec2_routes.py +0 -0
  59. {stackport-0.3.1 → stackport-0.3.3}/tests/test_endpoint_store.py +0 -0
  60. {stackport-0.3.1 → stackport-0.3.3}/tests/test_endpoints.py +0 -0
  61. {stackport-0.3.1 → stackport-0.3.3}/tests/test_iam_routes.py +0 -0
  62. {stackport-0.3.1 → stackport-0.3.3}/tests/test_logs_routes.py +0 -0
  63. {stackport-0.3.1 → stackport-0.3.3}/tests/test_multi_endpoint.py +0 -0
  64. {stackport-0.3.1 → stackport-0.3.3}/tests/test_readonly_middleware.py +0 -0
  65. {stackport-0.3.1 → stackport-0.3.3}/tests/test_registries.py +0 -0
  66. {stackport-0.3.1 → stackport-0.3.3}/tests/test_routes.py +0 -0
  67. {stackport-0.3.1 → stackport-0.3.3}/tests/test_s3_routes.py +0 -0
  68. {stackport-0.3.1 → stackport-0.3.3}/tests/test_s3_upload_limit_env.py +0 -0
  69. {stackport-0.3.1 → stackport-0.3.3}/tests/test_secretsmanager_routes.py +0 -0
  70. {stackport-0.3.1 → stackport-0.3.3}/tests/test_sqs_routes.py +0 -0
  71. {stackport-0.3.1 → stackport-0.3.3}/tests/test_tags_routes.py +0 -0
  72. {stackport-0.3.1 → stackport-0.3.3}/tests/test_websocket.py +0 -0
  73. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/acm.svg +0 -0
  74. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/apigateway.svg +0 -0
  75. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/appsync.svg +0 -0
  76. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/athena.svg +0 -0
  77. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/cloudformation.svg +0 -0
  78. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/cloudfront.svg +0 -0
  79. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/cognito-idp.svg +0 -0
  80. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/dynamodb.svg +0 -0
  81. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/ec2.svg +0 -0
  82. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/ecr.svg +0 -0
  83. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/ecs.svg +0 -0
  84. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/elasticache.svg +0 -0
  85. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
  86. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
  87. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
  88. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/events.svg +0 -0
  89. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/firehose.svg +0 -0
  90. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/glue.svg +0 -0
  91. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/iam.svg +0 -0
  92. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/kinesis.svg +0 -0
  93. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/kms.svg +0 -0
  94. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/lambda.svg +0 -0
  95. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/logs.svg +0 -0
  96. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/monitoring.svg +0 -0
  97. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/rds.svg +0 -0
  98. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/route53.svg +0 -0
  99. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/s3.svg +0 -0
  100. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/secretsmanager.svg +0 -0
  101. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/ses.svg +0 -0
  102. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/sns.svg +0 -0
  103. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/sqs.svg +0 -0
  104. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/ssm.svg +0 -0
  105. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/stepfunctions.svg +0 -0
  106. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/aws-icons/wafv2.svg +0 -0
  107. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/favicon.png +0 -0
  108. {stackport-0.3.1 → stackport-0.3.3}/ui/dist/favicon.svg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackport
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Universal AWS resource browser for local emulators
5
5
  Author: Davi Reis Vieira
6
6
  License: MIT
@@ -17,7 +17,7 @@ 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, s3, secretsmanager, sqs, stats, tags
20
+ from backend.routes import dynamodb, ec2, endpoints, iam, lambda_svc, logs, resources, s3, secretsmanager, sqs, stats, stepfunctions, tags
21
21
  from backend.websocket import probe_loop, websocket_endpoint
22
22
 
23
23
 
@@ -71,6 +71,7 @@ class ReadOnlyMiddleware(BaseHTTPMiddleware):
71
71
  return True
72
72
  if path.startswith("/api/lambda/functions/") and path.endswith("/invoke"):
73
73
  return True
74
+ # Step Functions start/stop execution are writes, not reads
74
75
  return False
75
76
 
76
77
  async def dispatch(self, request: Request, call_next):
@@ -103,7 +104,7 @@ app.add_middleware(ReadOnlyMiddleware)
103
104
  @app.exception_handler(SSOTokenLoadError)
104
105
  @app.exception_handler(UnauthorizedSSOTokenError)
105
106
  @app.exception_handler(SSOError)
106
- async def sso_error_handler(request: Request, exc: Exception):
107
+ async def sso_error_handler(_request: Request, exc: Exception):
107
108
  """Handle expired/missing SSO tokens with actionable guidance."""
108
109
  return JSONResponse(
109
110
  status_code=401,
@@ -122,6 +123,7 @@ app.include_router(iam.router, prefix="/api/iam", tags=["iam"])
122
123
  app.include_router(ec2.router, prefix="/api/ec2", tags=["ec2"])
123
124
  app.include_router(logs.router, prefix="/api/logs", tags=["logs"])
124
125
  app.include_router(secretsmanager.router, prefix="/api/secretsmanager", tags=["secretsmanager"])
126
+ app.include_router(stepfunctions.router, prefix="/api/stepfunctions", tags=["stepfunctions"])
125
127
  app.include_router(tags.router, prefix="/api", tags=["tags"])
126
128
  app.include_router(resources.router, prefix="/api")
127
129
 
@@ -9,6 +9,7 @@ from fastapi.responses import RedirectResponse
9
9
 
10
10
  from backend.aws_client import get_client
11
11
  from backend.routes.common import EndpointInfo, get_endpoint_info
12
+ from backend.schemas.lambda_svc import UpdateFunctionConfigRequest
12
13
 
13
14
  router = APIRouter()
14
15
 
@@ -184,3 +185,54 @@ def list_versions(function_name: str, ep: EndpointInfo = Depends(get_endpoint_in
184
185
  return {"versions": versions}
185
186
  except Exception as e:
186
187
  raise HTTPException(status_code=500, detail=str(e))
188
+
189
+
190
+ @router.patch("/functions/{function_name}/configuration")
191
+ def update_function_configuration(
192
+ function_name: str,
193
+ body: UpdateFunctionConfigRequest,
194
+ ep: EndpointInfo = Depends(get_endpoint_info)
195
+ ) -> dict[str, Any]:
196
+ """Update Lambda function configuration (partial updates supported).
197
+
198
+ All body fields are optional — only specified fields will be updated.
199
+ Returns the updated function configuration.
200
+ """
201
+ try:
202
+ client = get_client("lambda", **ep.client_kwargs())
203
+
204
+ # Build boto3 kwargs from request body, skipping None values
205
+ update_kwargs: dict[str, Any] = {"FunctionName": function_name}
206
+
207
+ if body.description is not None:
208
+ update_kwargs["Description"] = body.description
209
+
210
+ if body.handler is not None:
211
+ update_kwargs["Handler"] = body.handler
212
+
213
+ if body.runtime is not None:
214
+ update_kwargs["Runtime"] = body.runtime
215
+
216
+ if body.memory_size is not None:
217
+ update_kwargs["MemorySize"] = body.memory_size
218
+
219
+ if body.timeout is not None:
220
+ update_kwargs["Timeout"] = body.timeout
221
+
222
+ if body.environment is not None:
223
+ update_kwargs["Environment"] = {"Variables": body.environment}
224
+
225
+ if body.layers is not None:
226
+ update_kwargs["Layers"] = body.layers
227
+
228
+ # Call update_function_configuration
229
+ response = client.update_function_configuration(**update_kwargs)
230
+
231
+ return {"configuration": response}
232
+
233
+ except client.exceptions.ResourceNotFoundException:
234
+ raise HTTPException(status_code=404, detail=f"Function {function_name} not found")
235
+ except client.exceptions.InvalidParameterValueException as e:
236
+ raise HTTPException(status_code=400, detail=str(e))
237
+ except Exception as e:
238
+ raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,181 @@
1
+ """Step Functions service-specific routes."""
2
+
3
+ import json
4
+ from typing import Any
5
+ from urllib.parse import unquote
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Query
8
+
9
+ from backend.aws_client import get_client
10
+ from backend.routes.common import EndpointInfo, get_endpoint_info
11
+ from backend.schemas.stepfunctions import StartExecutionRequest, StopExecutionRequest
12
+
13
+ router = APIRouter()
14
+
15
+
16
+ @router.get("/state-machines")
17
+ def list_state_machines(ep: EndpointInfo = Depends(get_endpoint_info)) -> dict[str, Any]:
18
+ """List all Step Functions state machines."""
19
+ try:
20
+ client = get_client("stepfunctions", **ep.client_kwargs())
21
+ response = client.list_state_machines()
22
+ return {"stateMachines": response.get("stateMachines", [])}
23
+ except Exception as e:
24
+ raise HTTPException(status_code=500, detail=str(e)) from e
25
+
26
+
27
+ @router.get("/state-machines/{arn}")
28
+ def get_state_machine_detail(
29
+ arn: str,
30
+ ep: EndpointInfo = Depends(get_endpoint_info),
31
+ ) -> dict[str, Any]:
32
+ """Get state machine detail including ASL definition."""
33
+ decoded_arn = unquote(arn)
34
+ try:
35
+ client = get_client("stepfunctions", **ep.client_kwargs())
36
+ response = client.describe_state_machine(stateMachineArn=decoded_arn)
37
+
38
+ # Parse definition JSON string into dict
39
+ if "definition" in response:
40
+ try:
41
+ response["definition"] = json.loads(response["definition"])
42
+ except (json.JSONDecodeError, TypeError):
43
+ pass # Keep as string if parsing fails
44
+
45
+ return response
46
+ except client.exceptions.StateMachineDoesNotExist as e:
47
+ raise HTTPException(status_code=404, detail=f"State machine not found: {decoded_arn}") from e
48
+ except Exception as e:
49
+ raise HTTPException(status_code=500, detail=str(e)) from e
50
+
51
+
52
+ @router.get("/state-machines/{arn}/executions")
53
+ def list_executions(
54
+ arn: str,
55
+ status_filter: str | None = Query(None, alias="status_filter"),
56
+ max_results: int = Query(50, alias="max_results", ge=1, le=1000),
57
+ ep: EndpointInfo = Depends(get_endpoint_info),
58
+ ) -> dict[str, Any]:
59
+ """List executions for a state machine."""
60
+ decoded_arn = unquote(arn)
61
+ try:
62
+ client = get_client("stepfunctions", **ep.client_kwargs())
63
+ kwargs: dict[str, Any] = {
64
+ "stateMachineArn": decoded_arn,
65
+ "maxResults": max_results,
66
+ }
67
+ if status_filter:
68
+ kwargs["statusFilter"] = status_filter
69
+
70
+ response = client.list_executions(**kwargs)
71
+ return {"executions": response.get("executions", [])}
72
+ except Exception as e:
73
+ raise HTTPException(status_code=500, detail=str(e)) from e
74
+
75
+
76
+ @router.post("/state-machines/{arn}/executions")
77
+ def start_execution(
78
+ arn: str,
79
+ request: StartExecutionRequest,
80
+ ep: EndpointInfo = Depends(get_endpoint_info),
81
+ ) -> dict[str, Any]:
82
+ """Start a new execution of a state machine."""
83
+ decoded_arn = unquote(arn)
84
+ try:
85
+ client = get_client("stepfunctions", **ep.client_kwargs())
86
+ kwargs: dict[str, Any] = {"stateMachineArn": decoded_arn}
87
+
88
+ if request.name:
89
+ kwargs["name"] = request.name
90
+ if request.input is not None:
91
+ kwargs["input"] = json.dumps(request.input)
92
+
93
+ response = client.start_execution(**kwargs)
94
+ return {
95
+ "executionArn": response["executionArn"],
96
+ "startDate": response["startDate"].isoformat(),
97
+ }
98
+ except Exception as e:
99
+ raise HTTPException(status_code=500, detail=str(e)) from e
100
+
101
+
102
+ @router.get("/executions/{arn}")
103
+ def get_execution_detail(
104
+ arn: str,
105
+ ep: EndpointInfo = Depends(get_endpoint_info),
106
+ ) -> dict[str, Any]:
107
+ """Get execution detail."""
108
+ decoded_arn = unquote(arn)
109
+ try:
110
+ client = get_client("stepfunctions", **ep.client_kwargs())
111
+ response = client.describe_execution(executionArn=decoded_arn)
112
+
113
+ # Parse input/output JSON strings into dicts
114
+ for field in ["input", "output"]:
115
+ if field in response:
116
+ try:
117
+ response[field] = json.loads(response[field])
118
+ except (json.JSONDecodeError, TypeError):
119
+ pass # Keep as string if parsing fails
120
+
121
+ # Convert datetime objects to ISO strings
122
+ for date_field in ["startDate", "stopDate"]:
123
+ if date_field in response and response[date_field]:
124
+ response[date_field] = response[date_field].isoformat()
125
+
126
+ return response
127
+ except client.exceptions.ExecutionDoesNotExist as e:
128
+ raise HTTPException(status_code=404, detail=f"Execution not found: {decoded_arn}") from e
129
+ except Exception as e:
130
+ raise HTTPException(status_code=500, detail=str(e)) from e
131
+
132
+
133
+ @router.get("/executions/{arn}/history")
134
+ def get_execution_history(
135
+ arn: str,
136
+ max_results: int = Query(100, alias="max_results", ge=1, le=1000),
137
+ reverse_order: bool = Query(False, alias="reverse_order"),
138
+ ep: EndpointInfo = Depends(get_endpoint_info),
139
+ ) -> dict[str, Any]:
140
+ """Get execution history events."""
141
+ decoded_arn = unquote(arn)
142
+ try:
143
+ client = get_client("stepfunctions", **ep.client_kwargs())
144
+ response = client.get_execution_history(
145
+ executionArn=decoded_arn,
146
+ maxResults=max_results,
147
+ reverseOrder=reverse_order,
148
+ )
149
+
150
+ # Convert datetime objects to ISO strings in events
151
+ events = response.get("events", [])
152
+ for event in events:
153
+ if "timestamp" in event:
154
+ event["timestamp"] = event["timestamp"].isoformat()
155
+
156
+ return {"events": events}
157
+ except Exception as e:
158
+ raise HTTPException(status_code=500, detail=str(e)) from e
159
+
160
+
161
+ @router.post("/executions/{arn}/stop")
162
+ def stop_execution(
163
+ arn: str,
164
+ request: StopExecutionRequest,
165
+ ep: EndpointInfo = Depends(get_endpoint_info),
166
+ ) -> dict[str, Any]:
167
+ """Stop a running execution."""
168
+ decoded_arn = unquote(arn)
169
+ try:
170
+ client = get_client("stepfunctions", **ep.client_kwargs())
171
+ kwargs: dict[str, Any] = {"executionArn": decoded_arn}
172
+
173
+ if request.error:
174
+ kwargs["error"] = request.error
175
+ if request.cause:
176
+ kwargs["cause"] = request.cause
177
+
178
+ response = client.stop_execution(**kwargs)
179
+ return {"stopDate": response["stopDate"].isoformat()}
180
+ except Exception as e:
181
+ raise HTTPException(status_code=500, detail=str(e)) from e
@@ -0,0 +1,23 @@
1
+ """Pydantic schemas for Lambda API requests."""
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class UpdateFunctionConfigRequest(BaseModel):
7
+ """Request body for updating Lambda function configuration.
8
+
9
+ All fields are optional — only specified fields will be updated.
10
+ Validation follows AWS Lambda limits:
11
+ - Memory: 128-10240 MB
12
+ - Timeout: 1-900 seconds
13
+ """
14
+
15
+ model_config = ConfigDict(populate_by_name=True)
16
+
17
+ description: str | None = Field(None, description="Function description")
18
+ handler: str | None = Field(None, description="Handler path (e.g., index.handler)")
19
+ runtime: str | None = Field(None, description="Runtime identifier (e.g., python3.12, nodejs20.x)")
20
+ memory_size: int | None = Field(None, alias="memorySize", ge=128, le=10240, description="Memory in MB")
21
+ timeout: int | None = Field(None, ge=1, le=900, description="Timeout in seconds")
22
+ environment: dict[str, str] | None = Field(None, description="Environment variables as key-value dict")
23
+ layers: list[str] | None = Field(None, description="Layer ARNs")
@@ -0,0 +1,21 @@
1
+ """Pydantic schemas for Step Functions API requests."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class StartExecutionRequest(BaseModel):
7
+ """Request body for starting a Step Functions execution."""
8
+
9
+ name: str | None = Field(None, alias="name")
10
+ input: dict | None = Field(None, alias="input")
11
+
12
+ model_config = {"populate_by_name": True}
13
+
14
+
15
+ class StopExecutionRequest(BaseModel):
16
+ """Request body for stopping a Step Functions execution."""
17
+
18
+ error: str | None = Field(None, alias="error")
19
+ cause: str | None = Field(None, alias="cause")
20
+
21
+ model_config = {"populate_by_name": True}
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackport"
3
- version = "0.3.1"
3
+ version = "0.3.3"
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.3.1
3
+ Version: 0.3.3
4
4
  Summary: Universal AWS resource browser for local emulators
5
5
  Author: Davi Reis Vieira
6
6
  License: MIT
@@ -13,8 +13,10 @@ backend/websocket.py
13
13
  backend/../ui/dist/favicon.png
14
14
  backend/../ui/dist/favicon.svg
15
15
  backend/../ui/dist/index.html
16
- backend/../ui/dist/assets/index-CAZXHF4B.js
17
- backend/../ui/dist/assets/index-DLd-xBZq.css
16
+ backend/../ui/dist/assets/StateMachineGraph-DKisycYa.js
17
+ backend/../ui/dist/assets/index-CCOvZYbr.css
18
+ backend/../ui/dist/assets/index-XBYym2zo.js
19
+ backend/../ui/dist/assets/stepfunctions-graph-B6nvU4OB.js
18
20
  backend/../ui/dist/aws-icons/acm.svg
19
21
  backend/../ui/dist/aws-icons/apigateway.svg
20
22
  backend/../ui/dist/aws-icons/appsync.svg
@@ -62,13 +64,16 @@ backend/routes/s3.py
62
64
  backend/routes/secretsmanager.py
63
65
  backend/routes/sqs.py
64
66
  backend/routes/stats.py
67
+ backend/routes/stepfunctions.py
65
68
  backend/routes/tags.py
66
69
  backend/schemas/__init__.py
67
70
  backend/schemas/dynamodb.py
68
71
  backend/schemas/endpoints.py
72
+ backend/schemas/lambda_svc.py
69
73
  backend/schemas/s3.py
70
74
  backend/schemas/secretsmanager.py
71
75
  backend/schemas/sqs.py
76
+ backend/schemas/stepfunctions.py
72
77
  backend/schemas/tags.py
73
78
  stackport.egg-info/PKG-INFO
74
79
  stackport.egg-info/SOURCES.txt
@@ -100,8 +105,10 @@ tests/test_websocket.py
100
105
  ui/dist/favicon.png
101
106
  ui/dist/favicon.svg
102
107
  ui/dist/index.html
103
- ui/dist/assets/index-CAZXHF4B.js
104
- ui/dist/assets/index-DLd-xBZq.css
108
+ ui/dist/assets/StateMachineGraph-DKisycYa.js
109
+ ui/dist/assets/index-CCOvZYbr.css
110
+ ui/dist/assets/index-XBYym2zo.js
111
+ ui/dist/assets/stepfunctions-graph-B6nvU4OB.js
105
112
  ui/dist/aws-icons/acm.svg
106
113
  ui/dist/aws-icons/apigateway.svg
107
114
  ui/dist/aws-icons/appsync.svg
@@ -269,3 +269,184 @@ class TestListVersions:
269
269
  data = resp.json()
270
270
  assert len(data["versions"]) == 2
271
271
  assert data["versions"][0]["Version"] == "$LATEST"
272
+
273
+
274
+ class TestUpdateFunctionConfiguration:
275
+ @patch("backend.routes.lambda_svc.get_client")
276
+ def test_update_environment_variables(self, mock_get_client):
277
+ mock_lambda = MagicMock()
278
+ mock_get_client.return_value = mock_lambda
279
+ mock_lambda.update_function_configuration.return_value = {
280
+ "FunctionName": "my-func",
281
+ "Runtime": "python3.12",
282
+ "Handler": "handler.main",
283
+ "MemorySize": 256,
284
+ "Timeout": 30,
285
+ "Environment": {
286
+ "Variables": {
287
+ "KEY1": "value1",
288
+ "KEY2": "value2",
289
+ }
290
+ },
291
+ }
292
+
293
+ resp = client.patch(
294
+ "/api/lambda/functions/my-func/configuration",
295
+ json={
296
+ "environment": {
297
+ "KEY1": "value1",
298
+ "KEY2": "value2",
299
+ }
300
+ },
301
+ )
302
+ assert resp.status_code == 200
303
+ data = resp.json()
304
+ assert data["configuration"]["Environment"]["Variables"]["KEY1"] == "value1"
305
+ mock_lambda.update_function_configuration.assert_called_once()
306
+ call_args = mock_lambda.update_function_configuration.call_args[1]
307
+ assert call_args["FunctionName"] == "my-func"
308
+ assert call_args["Environment"]["Variables"] == {"KEY1": "value1", "KEY2": "value2"}
309
+
310
+ @patch("backend.routes.lambda_svc.get_client")
311
+ def test_update_memory_and_timeout(self, mock_get_client):
312
+ mock_lambda = MagicMock()
313
+ mock_get_client.return_value = mock_lambda
314
+ mock_lambda.update_function_configuration.return_value = {
315
+ "FunctionName": "my-func",
316
+ "MemorySize": 512,
317
+ "Timeout": 60,
318
+ }
319
+
320
+ resp = client.patch(
321
+ "/api/lambda/functions/my-func/configuration",
322
+ json={"memorySize": 512, "timeout": 60},
323
+ )
324
+ assert resp.status_code == 200
325
+ data = resp.json()
326
+ assert data["configuration"]["MemorySize"] == 512
327
+ assert data["configuration"]["Timeout"] == 60
328
+ mock_lambda.update_function_configuration.assert_called_once_with(
329
+ FunctionName="my-func",
330
+ MemorySize=512,
331
+ Timeout=60,
332
+ )
333
+
334
+ @patch("backend.routes.lambda_svc.get_client")
335
+ def test_update_handler_and_runtime(self, mock_get_client):
336
+ mock_lambda = MagicMock()
337
+ mock_get_client.return_value = mock_lambda
338
+ mock_lambda.update_function_configuration.return_value = {
339
+ "FunctionName": "my-func",
340
+ "Handler": "new_handler.handler",
341
+ "Runtime": "python3.13",
342
+ }
343
+
344
+ resp = client.patch(
345
+ "/api/lambda/functions/my-func/configuration",
346
+ json={"handler": "new_handler.handler", "runtime": "python3.13"},
347
+ )
348
+ assert resp.status_code == 200
349
+ data = resp.json()
350
+ assert data["configuration"]["Handler"] == "new_handler.handler"
351
+ assert data["configuration"]["Runtime"] == "python3.13"
352
+
353
+ @patch("backend.routes.lambda_svc.get_client")
354
+ def test_update_description(self, mock_get_client):
355
+ mock_lambda = MagicMock()
356
+ mock_get_client.return_value = mock_lambda
357
+ mock_lambda.update_function_configuration.return_value = {
358
+ "FunctionName": "my-func",
359
+ "Description": "Updated description",
360
+ }
361
+
362
+ resp = client.patch(
363
+ "/api/lambda/functions/my-func/configuration",
364
+ json={"description": "Updated description"},
365
+ )
366
+ assert resp.status_code == 200
367
+ data = resp.json()
368
+ assert data["configuration"]["Description"] == "Updated description"
369
+
370
+ @patch("backend.routes.lambda_svc.get_client")
371
+ def test_update_partial(self, mock_get_client):
372
+ """Test that only specified fields are included in the update call."""
373
+ mock_lambda = MagicMock()
374
+ mock_get_client.return_value = mock_lambda
375
+ mock_lambda.update_function_configuration.return_value = {
376
+ "FunctionName": "my-func",
377
+ "Timeout": 120,
378
+ }
379
+
380
+ resp = client.patch(
381
+ "/api/lambda/functions/my-func/configuration",
382
+ json={"timeout": 120},
383
+ )
384
+ assert resp.status_code == 200
385
+ # Verify only Timeout and FunctionName were passed
386
+ mock_lambda.update_function_configuration.assert_called_once()
387
+ call_args = mock_lambda.update_function_configuration.call_args[1]
388
+ assert set(call_args.keys()) == {"FunctionName", "Timeout"}
389
+ assert call_args["Timeout"] == 120
390
+
391
+ @patch("backend.routes.lambda_svc.get_client")
392
+ def test_update_function_not_found(self, mock_get_client):
393
+ mock_lambda = MagicMock()
394
+ mock_get_client.return_value = mock_lambda
395
+ # Set up both exception types
396
+ mock_lambda.exceptions.ResourceNotFoundException = type("ResourceNotFoundException", (Exception,), {})
397
+ mock_lambda.exceptions.InvalidParameterValueException = type("InvalidParameterValueException", (Exception,), {})
398
+ mock_lambda.update_function_configuration.side_effect = mock_lambda.exceptions.ResourceNotFoundException()
399
+
400
+ resp = client.patch(
401
+ "/api/lambda/functions/nonexistent/configuration",
402
+ json={"timeout": 60},
403
+ )
404
+ assert resp.status_code == 404
405
+
406
+ @patch("backend.routes.lambda_svc.get_client")
407
+ def test_update_invalid_parameter_boto3(self, mock_get_client):
408
+ """Test AWS InvalidParameterValueException returns 400."""
409
+ mock_lambda = MagicMock()
410
+ mock_get_client.return_value = mock_lambda
411
+ # Set up both exception types
412
+ mock_lambda.exceptions.ResourceNotFoundException = type("ResourceNotFoundException", (Exception,), {})
413
+ mock_lambda.exceptions.InvalidParameterValueException = type("InvalidParameterValueException", (Exception,), {})
414
+ mock_lambda.update_function_configuration.side_effect = mock_lambda.exceptions.InvalidParameterValueException("Invalid runtime")
415
+
416
+ resp = client.patch(
417
+ "/api/lambda/functions/my-func/configuration",
418
+ json={"runtime": "invalid_runtime"},
419
+ )
420
+ assert resp.status_code == 400
421
+
422
+ def test_update_validation_memory_too_low(self):
423
+ """Test Pydantic validation rejects memory < 128."""
424
+ resp = client.patch(
425
+ "/api/lambda/functions/my-func/configuration",
426
+ json={"memorySize": 64},
427
+ )
428
+ assert resp.status_code == 422
429
+
430
+ def test_update_validation_memory_too_high(self):
431
+ """Test Pydantic validation rejects memory > 10240."""
432
+ resp = client.patch(
433
+ "/api/lambda/functions/my-func/configuration",
434
+ json={"memorySize": 20480},
435
+ )
436
+ assert resp.status_code == 422
437
+
438
+ def test_update_validation_timeout_too_low(self):
439
+ """Test Pydantic validation rejects timeout < 1."""
440
+ resp = client.patch(
441
+ "/api/lambda/functions/my-func/configuration",
442
+ json={"timeout": 0},
443
+ )
444
+ assert resp.status_code == 422
445
+
446
+ def test_update_validation_timeout_too_high(self):
447
+ """Test Pydantic validation rejects timeout > 900."""
448
+ resp = client.patch(
449
+ "/api/lambda/functions/my-func/configuration",
450
+ json={"timeout": 1000},
451
+ )
452
+ assert resp.status_code == 422
@@ -0,0 +1,6 @@
1
+ import{c as T,C as E,a as P,b as C,G as D,j as r,r as f,B as k}from"./index-XBYym2zo.js";import{T as v}from"./stepfunctions-graph-B6nvU4OB.js";/**
2
+ * @license lucide-react v1.7.0 - ISC
3
+ *
4
+ * This source code is licensed under the ISC license.
5
+ * See the LICENSE file in the root directory of this source tree.
6
+ */const A=[["path",{d:"m17 2 4 4-4 4",key:"nntrym"}],["path",{d:"M3 11v-1a4 4 0 0 1 4-4h14",key:"84bu3i"}],["path",{d:"m7 22-4-4 4-4",key:"1wqhfi"}],["path",{d:"M21 13v1a4 4 0 0 1-4 4H3",key:"1rx37r"}]],q=T("repeat",A);function I(e){const t=typeof e=="string"?JSON.parse(e):e,n=t.StartAt||"",d=t.States||{},a=[],i=[];for(const[c,s]of Object.entries(d)){const l=s.Type||"Pass",u=l==="Succeed"||l==="Fail"||s.End===!0;if(a.push({id:c,type:l,isTerminal:u,metadata:s}),s.Next&&typeof s.Next=="string"&&i.push({source:c,target:s.Next,type:"next"}),l==="Choice"&&Array.isArray(s.Choices)){for(const h of s.Choices)if(h.Next&&typeof h.Next=="string"){const m=W(h);i.push({source:c,target:h.Next,label:m,type:"choice"})}s.Default&&typeof s.Default=="string"&&i.push({source:c,target:s.Default,label:"Default",type:"default"})}if(Array.isArray(s.Catch)){for(const h of s.Catch)if(h.Next&&typeof h.Next=="string"){const m=Array.isArray(h.ErrorEquals)?h.ErrorEquals.join(", "):"Error";i.push({source:c,target:h.Next,label:m,type:"catch"})}}}return{nodes:a,edges:i,startAt:n}}function W(e){const t=e.Variable,n=t?t.replace("$.",""):"",d=["StringEquals","StringEqualsPath","StringLessThan","StringGreaterThan","StringMatches","NumericEquals","NumericLessThan","NumericGreaterThan","NumericLessThanEquals","NumericGreaterThanEquals","BooleanEquals","TimestampEquals","TimestampLessThan","TimestampGreaterThan","IsPresent","IsNull","IsString","IsNumeric","IsBoolean","IsTimestamp"];for(const a of d)if(a in e){const i=e[a],c=a.replace("StringEquals","==").replace("NumericEquals","==").replace("NumericLessThan","<").replace("NumericGreaterThan",">").replace("BooleanEquals","==");return c!==a?`${n} ${c} ${i}`:`${n} ${a}`}return n||"?"}const b=160,y=48,L={Task:{bg:"fill-blue-500/10",border:"stroke-blue-500",text:"text-blue-400"},Pass:{bg:"fill-muted/50",border:"stroke-muted-foreground/50",text:"text-muted-foreground"},Choice:{bg:"fill-amber-500/10",border:"stroke-amber-500",text:"text-amber-400"},Wait:{bg:"fill-secondary/50",border:"stroke-secondary-foreground/50",text:"text-secondary-foreground"},Parallel:{bg:"fill-purple-500/10",border:"stroke-purple-500",text:"text-purple-400"},Map:{bg:"fill-indigo-500/10",border:"stroke-indigo-500",text:"text-indigo-400"},Succeed:{bg:"fill-green-500/10",border:"stroke-green-500",text:"text-green-400"},Fail:{bg:"fill-red-500/10",border:"stroke-red-500",text:"text-red-400"}},G={Choice:D,Wait:C,Map:q,Succeed:P,Fail:E};function z(e){switch(e){case"succeeded":return"stroke-green-500 stroke-[3]";case"failed":return"stroke-red-500 stroke-[3]";case"in-progress":return"stroke-blue-500 stroke-[3] animate-pulse";default:return""}}function B({node:e,x:t,y:n,traceStatus:d,isStart:a}){const i=L[e.type],c=G[e.type],s=z(d),l=d===void 0&&s===""||d?"":"opacity-40",u=e.type==="Succeed"||e.type==="Fail"?y/2:8;return r.jsxs("g",{transform:`translate(${t-b/2}, ${n-y/2})`,className:l,children:[a&&r.jsx("circle",{cx:-12,cy:y/2,r:5,className:"fill-green-500"}),r.jsx("rect",{width:b,height:y,rx:u,className:`${i.bg} ${s||i.border} stroke-[1.5]`,strokeDasharray:e.type==="Pass"?"4 2":void 0}),r.jsx("foreignObject",{width:b,height:y,children:r.jsxs("div",{className:"flex items-center justify-center gap-1.5 h-full px-2",children:[c&&r.jsx(c,{className:`h-3.5 w-3.5 flex-shrink-0 ${i.text}`}),r.jsx("span",{className:`text-xs font-medium truncate ${i.text}`,children:e.id})]})}),r.jsx("text",{x:b/2,y:y+14,textAnchor:"middle",fontSize:10,className:"fill-zinc-400 opacity-60",children:e.type})]})}function O({edge:e,points:t,highlighted:n}){if(t.length<2)return null;const d=Y(t),a=t.slice(-2),i=Math.atan2(a[1].y-a[0].y,a[1].x-a[0].x),c=F(e.type,n),s=e.type==="catch"?"4 3":e.type==="default"?"2 2":void 0,l=Math.floor(t.length/2),u=t[l];return r.jsxs("g",{children:[r.jsx("path",{d,fill:"none",stroke:c.stroke,strokeWidth:c.strokeWidth,opacity:c.opacity,strokeDasharray:s}),r.jsx("polygon",{points:_(a[1],i),fill:c.stroke,opacity:c.opacity}),e.label&&u&&r.jsxs("g",{children:[r.jsx("rect",{x:u.x-(e.label.length*3+12),y:u.y-18,width:e.label.length*6+24,height:18,rx:4,fill:"#18181b",fillOpacity:.92,stroke:e.type==="catch"?"#f8717140":e.type==="choice"?"#fbbf2440":"#3f3f46",strokeWidth:.75}),r.jsx("text",{x:u.x,y:u.y-6,textAnchor:"middle",fontSize:11,fontFamily:"monospace",fill:e.type==="catch"?"#f87171":e.type==="choice"?"#fbbf24":"#a1a1aa",children:e.label})]})]})}function F(e,t){if(t)return{stroke:"#22c55e",strokeWidth:2.5,opacity:1};switch(e){case"catch":return{stroke:"#f87171",strokeWidth:1.5,opacity:.8};case"choice":return{stroke:"#fbbf24",strokeWidth:1.5,opacity:.8};case"default":return{stroke:"#a1a1aa",strokeWidth:1.5,opacity:.6};default:return{stroke:"#a1a1aa",strokeWidth:1.5,opacity:.7}}}function Y(e){if(e.length<2)return"";let t=`M ${e[0].x} ${e[0].y}`;for(let n=1;n<e.length;n++)t+=` L ${e[n].x} ${e[n].y}`;return t}function _(e,t){const d=e.x-6*Math.cos(t-Math.PI/6),a=e.y-6*Math.sin(t-Math.PI/6),i=e.x-6*Math.cos(t+Math.PI/6),c=e.y-6*Math.sin(t+Math.PI/6);return`${e.x},${e.y} ${d},${a} ${i},${c}`}function H(e){const t=new v.graphlib.Graph;t.setGraph({rankdir:"TB",nodesep:80,ranksep:90,marginx:40,marginy:40}),t.setDefaultEdgeLabel(()=>({}));for(const s of e.nodes)t.setNode(s.id,{width:b,height:y+20});for(const s of e.edges)t.setEdge(s.source,s.target);v.layout(t);const n=new Map;for(const s of e.nodes){const l=t.node(s.id);l&&n.set(s.id,{x:l.x,y:l.y})}const d=new Map;for(const s of e.edges){const l=`${s.source}->${s.target}`,u=t.edge(s.source,s.target);u!=null&&u.points&&d.set(l,u.points)}const a=t.graph(),i=((a==null?void 0:a.width)||400)+80,c=((a==null?void 0:a.height)||300)+80;return{graph:e,nodePositions:n,edgePoints:d,width:i,height:c}}function U({definition:e,trace:t,onNodeClick:n}){const d=f.useRef(null),[a,i]=f.useState({x:0,y:0}),[c,s]=f.useState(1),[l,u]=f.useState(!1),[h,m]=f.useState({x:0,y:0}),g=f.useMemo(()=>{const o=I(e);return H(o)},[e]),S=f.useCallback(o=>{o.preventDefault();const x=o.deltaY>0?.9:1.1;s(p=>Math.max(.3,Math.min(3,p*x)))},[]),M=f.useCallback(o=>{o.button===0&&(u(!0),m({x:o.clientX-a.x,y:o.clientY-a.y}))},[a]),w=f.useCallback(o=>{l&&i({x:o.clientX-h.x,y:o.clientY-h.y})},[l,h]),N=f.useCallback(()=>{u(!1)},[]),$=f.useMemo(()=>{if(!t)return new Set;const o=new Set,x=Array.from(t.visitedStates.keys());for(let p=0;p<x.length-1;p++)o.add(`${x[p]}->${x[p+1]}`);return o},[t]),j=t&&t.visitedStates.size>0;return r.jsxs("div",{className:"relative w-full h-full min-h-[400px] border rounded-md bg-background/50 overflow-hidden select-none",children:[r.jsx("svg",{ref:d,className:"w-full h-full cursor-grab active:cursor-grabbing",viewBox:`0 0 ${g.width} ${g.height}`,onWheel:S,onMouseDown:M,onMouseMove:w,onMouseUp:N,onMouseLeave:N,children:r.jsxs("g",{transform:`translate(${a.x}, ${a.y}) scale(${c})`,children:[g.graph.edges.map(o=>{const x=`${o.source}->${o.target}`,p=g.edgePoints.get(x);return p?r.jsx(O,{edge:o,points:p,highlighted:j?$.has(x):void 0},x):null}),g.graph.nodes.map(o=>{const x=g.nodePositions.get(o.id);return x?r.jsx("g",{onClick:()=>n==null?void 0:n(o.id),className:n?"cursor-pointer":"",children:r.jsx(B,{node:o,x:x.x,y:x.y,isStart:o.id===g.graph.startAt,traceStatus:j?t.visitedStates.get(o.id):void 0})},o.id):null})]})}),r.jsxs("div",{className:"absolute bottom-2 left-2 flex flex-wrap gap-1",children:[r.jsxs(k,{variant:"outline",className:"text-[10px] px-1.5 py-0 bg-background/80",children:[r.jsx("div",{className:"w-2 h-2 rounded-sm bg-blue-500 mr-1"}),"Task"]}),r.jsxs(k,{variant:"outline",className:"text-[10px] px-1.5 py-0 bg-background/80",children:[r.jsx("div",{className:"w-2 h-2 rounded-sm bg-amber-500 mr-1"}),"Choice"]}),r.jsxs(k,{variant:"outline",className:"text-[10px] px-1.5 py-0 bg-background/80",children:[r.jsx("div",{className:"w-2 h-2 rounded-sm bg-green-500 mr-1"}),"Succeed"]}),r.jsxs(k,{variant:"outline",className:"text-[10px] px-1.5 py-0 bg-background/80",children:[r.jsx("div",{className:"w-2 h-2 rounded-sm bg-red-500 mr-1"}),"Fail"]})]}),r.jsxs("div",{className:"absolute top-2 right-2 flex gap-1",children:[r.jsx("button",{className:"w-6 h-6 rounded border bg-background/80 text-xs flex items-center justify-center hover:bg-accent",onClick:()=>s(o=>Math.min(3,o*1.2)),children:"+"}),r.jsx("button",{className:"w-6 h-6 rounded border bg-background/80 text-xs flex items-center justify-center hover:bg-accent",onClick:()=>s(o=>Math.max(.3,o*.8)),children:"−"}),r.jsx("button",{className:"w-6 h-6 rounded border bg-background/80 text-xs flex items-center justify-center hover:bg-accent",onClick:()=>{s(1),i({x:0,y:0})},children:"⟲"})]})]})}export{U as default};