cloudwright-ai-web 1.2.2__tar.gz → 1.3.0__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.
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/PKG-INFO +1 -1
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/__init__.py +1 -1
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/app.py +22 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/middleware.py +41 -1
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/routers/design.py +10 -6
- cloudwright_ai_web-1.3.0/cloudwright_web/routers/health.py +102 -0
- cloudwright_ai_web-1.3.0/tests/test_api_key_auth.py +98 -0
- cloudwright_ai_web-1.3.0/tests/test_design_returns_usage.py +108 -0
- cloudwright_ai_web-1.3.0/tests/test_health_endpoint.py +124 -0
- cloudwright_ai_web-1.3.0/tests/test_request_id.py +51 -0
- cloudwright_ai_web-1.2.2/cloudwright_web/routers/health.py +0 -43
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/.gitignore +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/README.md +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/py.typed +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/routers/__init__.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/routers/catalog.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/routers/chat.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/routers/cost.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/routers/diagram.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/routers/diff.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/routers/export.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/routers/modules.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/routers/validate.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/singletons.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/static/assets/index-BZV40eAE.css +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/static/assets/index-DoHV2oTE.js +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/static/index.html +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/cloudwright_web/streaming.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/index.html +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/package-lock.json +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/package.json +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/App.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/ArchitectureDiagram.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/BoundaryNode.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/CatalogDrawer.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/CloudServiceNode.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/CostTable.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/DiagramControls.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/DiagramLegend.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/ExportPanel.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/NodeSidePanel.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/SpecPanel.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/SummaryBar.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/ValidationPanel.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/lib/icons.ts +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/main.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/tsconfig.json +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/vite.config.ts +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/pyproject.toml +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/tests/__init__.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/tests/conftest.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/tests/test_agent_browser.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/tests/test_api.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/tests/test_api_behavioral.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/tests/test_diagram_api.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/tests/test_error_responses.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/tests/test_modules_api.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/tests/test_rate_limiting.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/tests/test_streaming_api.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/tests/test_usage_tracking.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cloudwright-ai-web
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Web UI for Cloudwright architecture intelligence
|
|
5
5
|
Project-URL: Homepage, https://github.com/xmpuspus/cloudwright
|
|
6
6
|
Project-URL: Repository, https://github.com/xmpuspus/cloudwright
|
|
@@ -14,6 +14,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|
|
14
14
|
from cloudwright_web import __version__
|
|
15
15
|
from cloudwright_web.middleware import ( # noqa: F401
|
|
16
16
|
PathTraversalMiddleware,
|
|
17
|
+
RequestIdMiddleware,
|
|
17
18
|
_rate_limiter,
|
|
18
19
|
_RateLimiter,
|
|
19
20
|
add_cors,
|
|
@@ -44,16 +45,37 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|
|
44
45
|
return response
|
|
45
46
|
|
|
46
47
|
|
|
48
|
+
def _docs_enabled() -> bool:
|
|
49
|
+
"""Whether to expose /docs, /redoc, and /openapi.json.
|
|
50
|
+
|
|
51
|
+
Disabled by default in production (CLOUDWRIGHT_ENV=production) to reduce
|
|
52
|
+
reconnaissance surface. Override with CLOUDWRIGHT_DOCS_ENABLED=true.
|
|
53
|
+
"""
|
|
54
|
+
explicit = os.environ.get("CLOUDWRIGHT_DOCS_ENABLED")
|
|
55
|
+
if explicit is not None:
|
|
56
|
+
return explicit.lower() == "true"
|
|
57
|
+
env = os.environ.get("CLOUDWRIGHT_ENV", "").lower()
|
|
58
|
+
return env != "production"
|
|
59
|
+
|
|
60
|
+
|
|
47
61
|
def create_app() -> FastAPI:
|
|
62
|
+
docs_on = _docs_enabled()
|
|
48
63
|
application = FastAPI(
|
|
49
64
|
title="Cloudwright",
|
|
50
65
|
version=__version__,
|
|
51
66
|
description="Architecture intelligence for cloud engineers",
|
|
67
|
+
docs_url="/docs" if docs_on else None,
|
|
68
|
+
redoc_url="/redoc" if docs_on else None,
|
|
69
|
+
openapi_url="/openapi.json" if docs_on else None,
|
|
52
70
|
)
|
|
53
71
|
|
|
72
|
+
# RequestIdMiddleware MUST be added LAST so Starlette dispatches it FIRST
|
|
73
|
+
# (Starlette runs middleware in reverse-add order). This way every later
|
|
74
|
+
# middleware's log lines carry the request_id.
|
|
54
75
|
application.add_middleware(SecurityHeadersMiddleware)
|
|
55
76
|
application.add_middleware(PathTraversalMiddleware)
|
|
56
77
|
add_cors(application)
|
|
78
|
+
application.add_middleware(RequestIdMiddleware)
|
|
57
79
|
|
|
58
80
|
application.include_router(health_router, prefix="/api")
|
|
59
81
|
application.include_router(design_router, prefix="/api")
|
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import hmac
|
|
5
6
|
import os
|
|
6
7
|
import threading
|
|
7
8
|
import time
|
|
9
|
+
import uuid
|
|
8
10
|
from collections import deque
|
|
9
11
|
from urllib.parse import unquote
|
|
10
12
|
|
|
13
|
+
import structlog
|
|
11
14
|
from fastapi import HTTPException, Request
|
|
12
15
|
from fastapi.middleware.cors import CORSMiddleware
|
|
13
16
|
from fastapi.responses import JSONResponse
|
|
@@ -22,6 +25,32 @@ class PathTraversalMiddleware(BaseHTTPMiddleware):
|
|
|
22
25
|
return await call_next(request)
|
|
23
26
|
|
|
24
27
|
|
|
28
|
+
class RequestIdMiddleware(BaseHTTPMiddleware):
|
|
29
|
+
"""Attach a request correlation ID to every request.
|
|
30
|
+
|
|
31
|
+
- Reads X-Request-Id from incoming headers, otherwise mints a UUID4 hex.
|
|
32
|
+
- Binds the value into structlog's contextvars for the duration of the
|
|
33
|
+
request so every log line carries it.
|
|
34
|
+
- Echoes the same value back as the X-Request-Id response header.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
async def dispatch(self, request: Request, call_next):
|
|
38
|
+
incoming = request.headers.get("x-request-id", "").strip()
|
|
39
|
+
request_id = incoming or uuid.uuid4().hex
|
|
40
|
+
|
|
41
|
+
# Stash on request.state so handlers can read it if they need to.
|
|
42
|
+
request.state.request_id = request_id
|
|
43
|
+
|
|
44
|
+
structlog.contextvars.bind_contextvars(request_id=request_id)
|
|
45
|
+
try:
|
|
46
|
+
response = await call_next(request)
|
|
47
|
+
finally:
|
|
48
|
+
structlog.contextvars.unbind_contextvars("request_id")
|
|
49
|
+
|
|
50
|
+
response.headers["X-Request-Id"] = request_id
|
|
51
|
+
return response
|
|
52
|
+
|
|
53
|
+
|
|
25
54
|
def add_cors(app):
|
|
26
55
|
origins = os.environ.get("CLOUDWRIGHT_CORS_ORIGINS", "http://localhost:5173,http://localhost:3000").split(",")
|
|
27
56
|
app.add_middleware(
|
|
@@ -38,10 +67,21 @@ _API_KEY = os.environ.get("CLOUDWRIGHT_API_KEY")
|
|
|
38
67
|
|
|
39
68
|
|
|
40
69
|
def check_api_key(request: Request):
|
|
70
|
+
"""Validate the X-API-Key header in constant time.
|
|
71
|
+
|
|
72
|
+
Using ``hmac.compare_digest`` prevents timing-based recovery of the
|
|
73
|
+
configured key. Both sides are encoded to bytes (utf-8); mismatched
|
|
74
|
+
lengths are rejected by ``compare_digest`` itself but we short-circuit
|
|
75
|
+
empty input first to avoid leaking even a length signal.
|
|
76
|
+
"""
|
|
41
77
|
if not _API_KEY:
|
|
42
78
|
return None
|
|
43
79
|
provided = request.headers.get("x-api-key", "")
|
|
44
|
-
if provided
|
|
80
|
+
if not provided:
|
|
81
|
+
raise HTTPException(status_code=401, detail="Invalid or missing API key")
|
|
82
|
+
expected_bytes = _API_KEY.encode("utf-8")
|
|
83
|
+
provided_bytes = provided.encode("utf-8")
|
|
84
|
+
if not hmac.compare_digest(provided_bytes, expected_bytes):
|
|
45
85
|
raise HTTPException(status_code=401, detail="Invalid or missing API key")
|
|
46
86
|
return None
|
|
47
87
|
|
|
@@ -52,7 +52,8 @@ async def design(req: DesignRequest, request: Request):
|
|
|
52
52
|
spec = spec.model_copy(update={"cost_estimate": cost_estimate})
|
|
53
53
|
except Exception:
|
|
54
54
|
log.warning("Cost estimation failed in design endpoint", exc_info=True)
|
|
55
|
-
|
|
55
|
+
usage = getattr(architect, "last_usage", None) or {}
|
|
56
|
+
return {"spec": spec.model_dump(exclude_none=True), "yaml": spec.to_yaml(), "usage": usage}
|
|
56
57
|
except RuntimeError as e:
|
|
57
58
|
if "No LLM provider" in str(e):
|
|
58
59
|
return error_response("missing_api_key", str(e), "Set an LLM provider API key in your environment", 503)
|
|
@@ -83,7 +84,8 @@ async def design_stream(req: DesignRequest, request: Request):
|
|
|
83
84
|
yield sse_event("error", message=str(e))
|
|
84
85
|
return
|
|
85
86
|
|
|
86
|
-
|
|
87
|
+
usage = getattr(architect, "last_usage", None) or {}
|
|
88
|
+
yield sse_event("generated", spec=spec.model_dump(exclude_none=True), yaml=spec.to_yaml(), usage=usage)
|
|
87
89
|
|
|
88
90
|
yield sse_event("costing", message="Estimating cost...")
|
|
89
91
|
try:
|
|
@@ -107,7 +109,7 @@ async def design_stream(req: DesignRequest, request: Request):
|
|
|
107
109
|
log.warning("Validation failed in design stream", exc_info=True)
|
|
108
110
|
yield sse_event("validated", passed=None, total=None)
|
|
109
111
|
|
|
110
|
-
yield sse_event("done", spec=spec.model_dump(exclude_none=True), yaml=spec.to_yaml())
|
|
112
|
+
yield sse_event("done", spec=spec.model_dump(exclude_none=True), yaml=spec.to_yaml(), usage=usage)
|
|
111
113
|
|
|
112
114
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|
113
115
|
|
|
@@ -124,7 +126,8 @@ async def modify(req: ModifyRequest, request: Request):
|
|
|
124
126
|
updated = await asyncio.wait_for(asyncio.to_thread(architect.modify, spec, req.instruction), timeout=120)
|
|
125
127
|
except asyncio.TimeoutError:
|
|
126
128
|
return error_response("llm_timeout", "Request timed out", "Try a simpler architecture description", 504)
|
|
127
|
-
|
|
129
|
+
usage = getattr(architect, "last_usage", None) or {}
|
|
130
|
+
return {"spec": updated.model_dump(exclude_none=True), "yaml": updated.to_yaml(), "usage": usage}
|
|
128
131
|
except Exception:
|
|
129
132
|
log.exception("Modify endpoint failed")
|
|
130
133
|
return error_response("internal_error", "Internal server error", "Check server logs for details", 500)
|
|
@@ -148,7 +151,8 @@ async def modify_stream(req: ModifyRequest, request: Request):
|
|
|
148
151
|
yield sse_event("error", message=str(e))
|
|
149
152
|
return
|
|
150
153
|
|
|
151
|
-
|
|
154
|
+
usage = getattr(architect, "last_usage", None) or {}
|
|
155
|
+
yield sse_event("modified", spec=updated.model_dump(exclude_none=True), yaml=updated.to_yaml(), usage=usage)
|
|
152
156
|
|
|
153
157
|
yield sse_event("costing", message="Estimating cost...")
|
|
154
158
|
try:
|
|
@@ -159,6 +163,6 @@ async def modify_stream(req: ModifyRequest, request: Request):
|
|
|
159
163
|
log.warning("Cost estimation failed in modify stream", exc_info=True)
|
|
160
164
|
yield sse_event("costed", cost_estimate=None)
|
|
161
165
|
|
|
162
|
-
yield sse_event("done", spec=updated.model_dump(exclude_none=True), yaml=updated.to_yaml())
|
|
166
|
+
yield sse_event("done", spec=updated.model_dump(exclude_none=True), yaml=updated.to_yaml(), usage=usage)
|
|
163
167
|
|
|
164
168
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""GET /api/health, /api/version, and static icon serving."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, HTTPException
|
|
10
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
11
|
+
|
|
12
|
+
import cloudwright_web.singletons as _singletons
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
|
|
16
|
+
# Captured at module import for uptime reporting.
|
|
17
|
+
_START_MONOTONIC = time.monotonic()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _llm_provider_and_model() -> tuple[str | None, str | None]:
|
|
21
|
+
"""Return (provider, model_name) based on env, without instantiating clients."""
|
|
22
|
+
if os.environ.get("ANTHROPIC_API_KEY"):
|
|
23
|
+
from cloudwright.llm.anthropic import GENERATE_MODEL as ANTHROPIC_MODEL
|
|
24
|
+
|
|
25
|
+
return "anthropic", ANTHROPIC_MODEL
|
|
26
|
+
if os.environ.get("OPENAI_API_KEY"):
|
|
27
|
+
from cloudwright.llm.openai import GENERATE_MODEL as OPENAI_MODEL
|
|
28
|
+
|
|
29
|
+
return "openai", OPENAI_MODEL
|
|
30
|
+
return None, None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _version_payload() -> dict:
|
|
34
|
+
from cloudwright import __version__
|
|
35
|
+
|
|
36
|
+
provider, model = _llm_provider_and_model()
|
|
37
|
+
return {
|
|
38
|
+
"version": __version__,
|
|
39
|
+
"build_sha": os.environ.get("CLOUDWRIGHT_BUILD_SHA"),
|
|
40
|
+
"llm_provider": provider,
|
|
41
|
+
"llm_model": model,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@router.get("/health")
|
|
46
|
+
def health():
|
|
47
|
+
has_llm_key = bool(os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY"))
|
|
48
|
+
if not has_llm_key:
|
|
49
|
+
return JSONResponse(
|
|
50
|
+
status_code=503,
|
|
51
|
+
content={
|
|
52
|
+
"status": "degraded",
|
|
53
|
+
"reason": "No LLM API key configured (ANTHROPIC_API_KEY or OPENAI_API_KEY)",
|
|
54
|
+
**_version_payload(),
|
|
55
|
+
"catalog_loaded": False,
|
|
56
|
+
"catalog_size": 0,
|
|
57
|
+
"uptime_s": round(time.monotonic() - _START_MONOTONIC, 3),
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
catalog_loaded = False
|
|
62
|
+
catalog_size = 0
|
|
63
|
+
try:
|
|
64
|
+
catalog = _singletons.get_catalog()
|
|
65
|
+
# Try to size the catalog; fall back to a sample search if no len.
|
|
66
|
+
try:
|
|
67
|
+
catalog_size = len(catalog) # type: ignore[arg-type]
|
|
68
|
+
except TypeError:
|
|
69
|
+
catalog_size = len(catalog.search(query="m5", limit=1))
|
|
70
|
+
catalog_loaded = True
|
|
71
|
+
except Exception:
|
|
72
|
+
catalog_loaded = False
|
|
73
|
+
|
|
74
|
+
body = {
|
|
75
|
+
"status": "ok" if catalog_loaded else "degraded",
|
|
76
|
+
**_version_payload(),
|
|
77
|
+
"catalog_loaded": catalog_loaded,
|
|
78
|
+
"catalog_size": catalog_size,
|
|
79
|
+
"uptime_s": round(time.monotonic() - _START_MONOTONIC, 3),
|
|
80
|
+
}
|
|
81
|
+
if not catalog_loaded:
|
|
82
|
+
# Readiness probes (Kubernetes) should treat catalog-load failure as Not Ready.
|
|
83
|
+
return JSONResponse(status_code=503, content=body)
|
|
84
|
+
return body
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@router.get("/version")
|
|
88
|
+
def version():
|
|
89
|
+
return _version_payload()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@router.get("/icons/{provider}/{service}.svg")
|
|
93
|
+
def get_icon(provider: str, service: str):
|
|
94
|
+
import cloudwright
|
|
95
|
+
|
|
96
|
+
icons_dir = Path(cloudwright.__file__).parent / "data" / "icons"
|
|
97
|
+
icon_path = icons_dir / provider / f"{service}.svg"
|
|
98
|
+
if not icon_path.exists():
|
|
99
|
+
raise HTTPException(status_code=404, detail=f"Icon not found: {provider}/{service}")
|
|
100
|
+
if not icon_path.resolve().is_relative_to(icons_dir.resolve()):
|
|
101
|
+
raise HTTPException(status_code=404, detail="Invalid path")
|
|
102
|
+
return FileResponse(str(icon_path), media_type="image/svg+xml")
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Tests for the constant-time API key check.
|
|
2
|
+
|
|
3
|
+
The pre-v1.3 ``check_api_key`` used ``provided != _API_KEY`` which leaks the
|
|
4
|
+
key length and (under some interpreters) prefix-match timing. v1.3 switches
|
|
5
|
+
to ``hmac.compare_digest`` on bytes, plus an explicit empty-input short-circuit.
|
|
6
|
+
|
|
7
|
+
Tests patch the module-level ``_API_KEY`` directly via monkeypatch and restore
|
|
8
|
+
it on teardown — we avoid ``importlib.reload`` because reloading the
|
|
9
|
+
middleware module wipes the singleton ``_rate_limiter`` and breaks
|
|
10
|
+
unrelated tests that rely on the same instance.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import cloudwright_web.middleware as middleware
|
|
16
|
+
import pytest
|
|
17
|
+
from fastapi import HTTPException
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _StubRequest:
|
|
21
|
+
"""Just enough of starlette.Request to drive ``check_api_key``."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, header_value: str | None):
|
|
24
|
+
self.headers = {"x-api-key": header_value} if header_value is not None else {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def with_key(monkeypatch):
|
|
29
|
+
"""Set the configured API key for the duration of one test."""
|
|
30
|
+
|
|
31
|
+
def _set(value: str | None):
|
|
32
|
+
monkeypatch.setattr(middleware, "_API_KEY", value)
|
|
33
|
+
|
|
34
|
+
return _set
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_returns_none_when_no_api_key_configured(with_key):
|
|
38
|
+
with_key(None)
|
|
39
|
+
# Even an explicitly garbage header is allowed when no key is set.
|
|
40
|
+
assert middleware.check_api_key(_StubRequest("anything")) is None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_correct_key_passes(with_key):
|
|
44
|
+
with_key("secret-token-abc123")
|
|
45
|
+
assert middleware.check_api_key(_StubRequest("secret-token-abc123")) is None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_wrong_key_same_length_raises_401(with_key):
|
|
49
|
+
"""Two equal-length keys must compare via hmac.compare_digest."""
|
|
50
|
+
with_key("secret-token-abc123")
|
|
51
|
+
with pytest.raises(HTTPException) as exc:
|
|
52
|
+
middleware.check_api_key(_StubRequest("secret-token-XYZ999"))
|
|
53
|
+
assert exc.value.status_code == 401
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_wrong_key_different_length_raises_401(with_key):
|
|
57
|
+
with_key("secret-token-abc123")
|
|
58
|
+
with pytest.raises(HTTPException) as exc:
|
|
59
|
+
middleware.check_api_key(_StubRequest("short"))
|
|
60
|
+
assert exc.value.status_code == 401
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_missing_header_raises_401(with_key):
|
|
64
|
+
with_key("secret-token-abc123")
|
|
65
|
+
with pytest.raises(HTTPException) as exc:
|
|
66
|
+
middleware.check_api_key(_StubRequest(None))
|
|
67
|
+
assert exc.value.status_code == 401
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_empty_header_raises_401(with_key):
|
|
71
|
+
with_key("secret-token-abc123")
|
|
72
|
+
with pytest.raises(HTTPException) as exc:
|
|
73
|
+
middleware.check_api_key(_StubRequest(""))
|
|
74
|
+
assert exc.value.status_code == 401
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_uses_hmac_compare_digest(with_key, monkeypatch):
|
|
78
|
+
"""Sanity check: the implementation actually calls hmac.compare_digest.
|
|
79
|
+
|
|
80
|
+
We monkeypatch ``hmac.compare_digest`` and assert it is invoked with the
|
|
81
|
+
UTF-8 encoded bytes of both sides — this pins the constant-time semantic.
|
|
82
|
+
"""
|
|
83
|
+
with_key("abc")
|
|
84
|
+
calls: list[tuple[bytes, bytes]] = []
|
|
85
|
+
real = middleware.hmac.compare_digest
|
|
86
|
+
|
|
87
|
+
def spy(a, b):
|
|
88
|
+
calls.append((a, b))
|
|
89
|
+
return real(a, b)
|
|
90
|
+
|
|
91
|
+
monkeypatch.setattr(middleware.hmac, "compare_digest", spy)
|
|
92
|
+
with pytest.raises(HTTPException):
|
|
93
|
+
middleware.check_api_key(_StubRequest("xyz"))
|
|
94
|
+
assert calls, "hmac.compare_digest should have been called"
|
|
95
|
+
a, b = calls[0]
|
|
96
|
+
assert isinstance(a, bytes) and isinstance(b, bytes)
|
|
97
|
+
assert a == b"xyz"
|
|
98
|
+
assert b == b"abc"
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""/api/design and /api/modify must surface LLM usage in the response.
|
|
2
|
+
|
|
3
|
+
Pre-fix: only /api/chat returned usage; design and modify dropped it. UI
|
|
4
|
+
clients had no way to surface tokens or cost for those endpoints.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from unittest.mock import MagicMock, patch
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from fastapi.testclient import TestClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def client():
|
|
17
|
+
from cloudwright_web.app import app
|
|
18
|
+
|
|
19
|
+
return TestClient(app)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _make_spec():
|
|
23
|
+
from cloudwright.spec import ArchSpec, Component, Connection
|
|
24
|
+
|
|
25
|
+
return ArchSpec(
|
|
26
|
+
name="Test App",
|
|
27
|
+
provider="aws",
|
|
28
|
+
region="us-east-1",
|
|
29
|
+
components=[
|
|
30
|
+
Component(id="web", service="ec2", provider="aws", label="Web", tier=2, config={}),
|
|
31
|
+
Component(id="db", service="rds", provider="aws", label="DB", tier=3, config={}),
|
|
32
|
+
],
|
|
33
|
+
connections=[Connection(source="web", target="db", label="SQL")],
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TestDesignReturnsUsage:
|
|
38
|
+
def test_design_response_includes_usage_field(self, client):
|
|
39
|
+
spec = _make_spec()
|
|
40
|
+
architect = MagicMock()
|
|
41
|
+
architect.design.return_value = spec
|
|
42
|
+
architect.last_usage = {
|
|
43
|
+
"model": "claude-sonnet-4-6",
|
|
44
|
+
"input_tokens": 500,
|
|
45
|
+
"output_tokens": 200,
|
|
46
|
+
"cached_tokens": 0,
|
|
47
|
+
"cost_usd": 0.0045,
|
|
48
|
+
"latency_ms": 1234,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
with patch("cloudwright_web.singletons.get_architect", return_value=architect):
|
|
52
|
+
resp = client.post(
|
|
53
|
+
"/api/design",
|
|
54
|
+
json={"description": "3-tier web app on AWS", "provider": "aws", "region": "us-east-1"},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
assert resp.status_code == 200, resp.text
|
|
58
|
+
data = resp.json()
|
|
59
|
+
assert "usage" in data
|
|
60
|
+
usage = data["usage"]
|
|
61
|
+
assert usage["model"] == "claude-sonnet-4-6"
|
|
62
|
+
assert usage["input_tokens"] == 500
|
|
63
|
+
assert usage["output_tokens"] == 200
|
|
64
|
+
assert usage["cost_usd"] == 0.0045
|
|
65
|
+
|
|
66
|
+
def test_modify_response_includes_usage_field(self, client):
|
|
67
|
+
original = _make_spec()
|
|
68
|
+
modified = original
|
|
69
|
+
architect = MagicMock()
|
|
70
|
+
architect.modify.return_value = modified
|
|
71
|
+
architect.last_usage = {
|
|
72
|
+
"model": "claude-haiku-4-5-20251001",
|
|
73
|
+
"input_tokens": 100,
|
|
74
|
+
"output_tokens": 50,
|
|
75
|
+
"cached_tokens": 80,
|
|
76
|
+
"cost_usd": 0.00028,
|
|
77
|
+
"latency_ms": 400,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
with patch("cloudwright_web.singletons.get_architect", return_value=architect):
|
|
81
|
+
resp = client.post(
|
|
82
|
+
"/api/modify",
|
|
83
|
+
json={"spec": original.model_dump(exclude_none=True), "instruction": "add a cache layer"},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
assert resp.status_code == 200, resp.text
|
|
87
|
+
data = resp.json()
|
|
88
|
+
assert "usage" in data
|
|
89
|
+
# Haiku model surfaces — and the cost is the cheap rate, not Sonnet's.
|
|
90
|
+
assert data["usage"]["model"].startswith("claude-haiku")
|
|
91
|
+
assert data["usage"]["cost_usd"] == 0.00028
|
|
92
|
+
|
|
93
|
+
def test_design_usage_empty_when_unavailable(self, client):
|
|
94
|
+
"""Backwards-compat: if architect.last_usage is empty (e.g. template
|
|
95
|
+
match path), the response still includes the field but as {}."""
|
|
96
|
+
spec = _make_spec()
|
|
97
|
+
architect = MagicMock()
|
|
98
|
+
architect.design.return_value = spec
|
|
99
|
+
architect.last_usage = {}
|
|
100
|
+
|
|
101
|
+
with patch("cloudwright_web.singletons.get_architect", return_value=architect):
|
|
102
|
+
resp = client.post(
|
|
103
|
+
"/api/design",
|
|
104
|
+
json={"description": "3-tier web app on AWS", "provider": "aws", "region": "us-east-1"},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
assert resp.status_code == 200
|
|
108
|
+
assert resp.json()["usage"] == {}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Tests for /api/health and /api/version (audit-fix v1.3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def client(monkeypatch):
|
|
16
|
+
monkeypatch.setenv("CLOUDWRIGHT_API_KEY", "test-key")
|
|
17
|
+
from cloudwright_web.app import create_app
|
|
18
|
+
from fastapi.testclient import TestClient
|
|
19
|
+
|
|
20
|
+
return TestClient(create_app())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_health_returns_version_and_uptime(client):
|
|
24
|
+
r = client.get("/api/health", headers={"X-API-Key": "test-key"})
|
|
25
|
+
# status code may be 200 or 503 depending on catalog load in test env
|
|
26
|
+
body = r.json()
|
|
27
|
+
assert "version" in body
|
|
28
|
+
assert isinstance(body["version"], str)
|
|
29
|
+
assert "uptime_s" in body
|
|
30
|
+
assert isinstance(body["uptime_s"], (int, float))
|
|
31
|
+
assert "catalog_loaded" in body
|
|
32
|
+
assert "catalog_size" in body
|
|
33
|
+
assert "llm_provider" in body
|
|
34
|
+
assert "llm_model" in body
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_health_503_when_catalog_fails(client):
|
|
38
|
+
"""When catalog loading fails, health returns 503 (Kubernetes readiness)."""
|
|
39
|
+
with patch("cloudwright_web.singletons.get_catalog", side_effect=RuntimeError("catalog dead")):
|
|
40
|
+
r = client.get("/api/health", headers={"X-API-Key": "test-key"})
|
|
41
|
+
assert r.status_code == 503
|
|
42
|
+
body = r.json()
|
|
43
|
+
assert body["catalog_loaded"] is False
|
|
44
|
+
assert body["status"] == "degraded"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_health_503_when_no_llm_key(monkeypatch):
|
|
48
|
+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
|
49
|
+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
50
|
+
monkeypatch.setenv("CLOUDWRIGHT_API_KEY", "test-key")
|
|
51
|
+
from cloudwright_web.app import create_app
|
|
52
|
+
from fastapi.testclient import TestClient
|
|
53
|
+
|
|
54
|
+
c = TestClient(create_app())
|
|
55
|
+
r = c.get("/api/health")
|
|
56
|
+
assert r.status_code == 503
|
|
57
|
+
body = r.json()
|
|
58
|
+
assert body["status"] == "degraded"
|
|
59
|
+
assert "version" in body
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_version_endpoint(client):
|
|
63
|
+
r = client.get("/api/version")
|
|
64
|
+
assert r.status_code == 200
|
|
65
|
+
body = r.json()
|
|
66
|
+
assert set(body.keys()) == {"version", "build_sha", "llm_provider", "llm_model"}
|
|
67
|
+
assert isinstance(body["version"], str)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_version_includes_build_sha_from_env(monkeypatch):
|
|
71
|
+
monkeypatch.setenv("CLOUDWRIGHT_API_KEY", "test-key")
|
|
72
|
+
monkeypatch.setenv("CLOUDWRIGHT_BUILD_SHA", "abc1234")
|
|
73
|
+
from cloudwright_web.app import create_app
|
|
74
|
+
from fastapi.testclient import TestClient
|
|
75
|
+
|
|
76
|
+
c = TestClient(create_app())
|
|
77
|
+
r = c.get("/api/version")
|
|
78
|
+
assert r.json()["build_sha"] == "abc1234"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_openapi_json(response) -> bool:
|
|
82
|
+
"""A real OpenAPI doc is JSON whose body starts with `{"openapi"`.
|
|
83
|
+
|
|
84
|
+
The SPA fallback (frontend index.html) returns 200 with text/html, which
|
|
85
|
+
we treat as "openapi disabled" — Swagger UI cannot render against it.
|
|
86
|
+
"""
|
|
87
|
+
if response.status_code != 200:
|
|
88
|
+
return False
|
|
89
|
+
if "application/json" not in response.headers.get("content-type", ""):
|
|
90
|
+
return False
|
|
91
|
+
return response.text.lstrip().startswith('{"openapi"')
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_docs_disabled_in_production(monkeypatch):
|
|
95
|
+
monkeypatch.setenv("CLOUDWRIGHT_ENV", "production")
|
|
96
|
+
monkeypatch.delenv("CLOUDWRIGHT_DOCS_ENABLED", raising=False)
|
|
97
|
+
monkeypatch.setenv("CLOUDWRIGHT_API_KEY", "test-key")
|
|
98
|
+
from cloudwright_web.app import create_app
|
|
99
|
+
from fastapi.testclient import TestClient
|
|
100
|
+
|
|
101
|
+
c = TestClient(create_app())
|
|
102
|
+
assert not _is_openapi_json(c.get("/openapi.json"))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_docs_enabled_outside_production(monkeypatch):
|
|
106
|
+
monkeypatch.delenv("CLOUDWRIGHT_ENV", raising=False)
|
|
107
|
+
monkeypatch.delenv("CLOUDWRIGHT_DOCS_ENABLED", raising=False)
|
|
108
|
+
monkeypatch.setenv("CLOUDWRIGHT_API_KEY", "test-key")
|
|
109
|
+
from cloudwright_web.app import create_app
|
|
110
|
+
from fastapi.testclient import TestClient
|
|
111
|
+
|
|
112
|
+
c = TestClient(create_app())
|
|
113
|
+
assert _is_openapi_json(c.get("/openapi.json"))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_docs_enabled_override(monkeypatch):
|
|
117
|
+
monkeypatch.setenv("CLOUDWRIGHT_ENV", "production")
|
|
118
|
+
monkeypatch.setenv("CLOUDWRIGHT_DOCS_ENABLED", "true")
|
|
119
|
+
monkeypatch.setenv("CLOUDWRIGHT_API_KEY", "test-key")
|
|
120
|
+
from cloudwright_web.app import create_app
|
|
121
|
+
from fastapi.testclient import TestClient
|
|
122
|
+
|
|
123
|
+
c = TestClient(create_app())
|
|
124
|
+
assert _is_openapi_json(c.get("/openapi.json"))
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Tests for RequestIdMiddleware (audit-fix v1.3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def client(monkeypatch):
|
|
15
|
+
monkeypatch.setenv("CLOUDWRIGHT_API_KEY", "test-key")
|
|
16
|
+
from cloudwright_web.app import create_app
|
|
17
|
+
from fastapi.testclient import TestClient
|
|
18
|
+
|
|
19
|
+
return TestClient(create_app())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_request_id_minted_when_missing(client):
|
|
23
|
+
r = client.get("/api/version")
|
|
24
|
+
assert r.status_code == 200
|
|
25
|
+
rid = r.headers.get("X-Request-Id")
|
|
26
|
+
assert rid is not None
|
|
27
|
+
assert len(rid) >= 16
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_request_id_preserved_when_provided(client):
|
|
31
|
+
incoming = "deadbeefdeadbeefdeadbeefdeadbeef"
|
|
32
|
+
r = client.get("/api/version", headers={"X-Request-Id": incoming})
|
|
33
|
+
assert r.status_code == 200
|
|
34
|
+
assert r.headers["X-Request-Id"] == incoming
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_request_ids_are_unique_per_request(client):
|
|
38
|
+
rid1 = client.get("/api/version").headers["X-Request-Id"]
|
|
39
|
+
rid2 = client.get("/api/version").headers["X-Request-Id"]
|
|
40
|
+
assert rid1 != rid2
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_request_id_present_on_404(client):
|
|
44
|
+
r = client.get("/api/this-does-not-exist")
|
|
45
|
+
# path traversal middleware lets unknown routes through to 404 from FastAPI
|
|
46
|
+
assert "X-Request-Id" in r.headers
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_request_id_present_on_health(client):
|
|
50
|
+
r = client.get("/api/health", headers={"X-API-Key": "test-key"})
|
|
51
|
+
assert "X-Request-Id" in r.headers
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
"""GET /api/health and static icon serving."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import os
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
from fastapi import APIRouter, HTTPException
|
|
9
|
-
from fastapi.responses import FileResponse, JSONResponse
|
|
10
|
-
|
|
11
|
-
import cloudwright_web.singletons as _singletons
|
|
12
|
-
|
|
13
|
-
router = APIRouter()
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@router.get("/health")
|
|
17
|
-
def health():
|
|
18
|
-
# Check LLM key presence
|
|
19
|
-
has_llm_key = bool(os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY"))
|
|
20
|
-
if not has_llm_key:
|
|
21
|
-
return JSONResponse(
|
|
22
|
-
status_code=503,
|
|
23
|
-
content={"status": "degraded", "reason": "No LLM API key configured (ANTHROPIC_API_KEY or OPENAI_API_KEY)"},
|
|
24
|
-
)
|
|
25
|
-
try:
|
|
26
|
-
catalog = _singletons.get_catalog()
|
|
27
|
-
results = catalog.search(query="m5", limit=1)
|
|
28
|
-
return {"status": "ok", "catalog_loaded": True, "sample_count": len(results)}
|
|
29
|
-
except Exception:
|
|
30
|
-
return {"status": "ok", "catalog_loaded": False}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
@router.get("/icons/{provider}/{service}.svg")
|
|
34
|
-
def get_icon(provider: str, service: str):
|
|
35
|
-
import cloudwright
|
|
36
|
-
|
|
37
|
-
icons_dir = Path(cloudwright.__file__).parent / "data" / "icons"
|
|
38
|
-
icon_path = icons_dir / provider / f"{service}.svg"
|
|
39
|
-
if not icon_path.exists():
|
|
40
|
-
raise HTTPException(status_code=404, detail=f"Icon not found: {provider}/{service}")
|
|
41
|
-
if not icon_path.resolve().is_relative_to(icons_dir.resolve()):
|
|
42
|
-
raise HTTPException(status_code=404, detail="Invalid path")
|
|
43
|
-
return FileResponse(str(icon_path), media_type="image/svg+xml")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/BoundaryNode.tsx
RENAMED
|
File without changes
|
{cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/CatalogDrawer.tsx
RENAMED
|
File without changes
|
{cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/CloudServiceNode.tsx
RENAMED
|
File without changes
|
|
File without changes
|
{cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/DiagramControls.tsx
RENAMED
|
File without changes
|
{cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/DiagramLegend.tsx
RENAMED
|
File without changes
|
{cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/ExportPanel.tsx
RENAMED
|
File without changes
|
{cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/NodeSidePanel.tsx
RENAMED
|
File without changes
|
|
File without changes
|
{cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/SummaryBar.tsx
RENAMED
|
File without changes
|
{cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.3.0}/frontend/src/components/ValidationPanel.tsx
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|