From d977a953ccea0bfbe837231781b949ab57a1b8ca Mon Sep 17 00:00:00 2001 From: gabdevele Date: Tue, 5 May 2026 15:01:14 +0200 Subject: [PATCH] refactor: remove Redis dependency and related code --- README.md | 5 +- TESTING.md | 5 +- backend/.env.example | 1 - backend/Dockerfile | 1 - backend/app/api/proxy/router.py | 172 ++++++++++++-- backend/app/core/config.py | 1 - backend/app/core/redis.py | 22 -- backend/app/main.py | 3 - backend/pyproject.toml | 1 - backend/tests/api/test_proxy.py | 86 +++++++ backend/tests/conftest.py | 1 - backend/uv.lock | 11 - docker-compose.yml | 20 -- frontend/src/components/CodeEditor.vue | 8 +- frontend/src/components/EditorPane.vue | 20 +- frontend/src/composables/useControlPlane.ts | 249 ++++++++++++++++---- frontend/src/styles.css | 44 +++- frontend/vite.config.ts | 2 +- playground/echo/docker-compose.bak.yml | 2 +- playground/echo/docker-compose.yml | 2 +- setup.sh | 6 +- 21 files changed, 504 insertions(+), 158 deletions(-) delete mode 100644 backend/app/core/redis.py diff --git a/README.md b/README.md index 8ba2fcd..90fc520 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Compose. ## Struttura ```text -backend/ FastAPI, MongoDB, Redis, mitmdump manager +backend/ FastAPI, MongoDB, mitmdump manager frontend/ Vue 3, Vite, CodeMirror + Python LSP playground/ servizio locale minimale per test end-to-end ``` @@ -41,7 +41,7 @@ Per installare anche il servizio systemd user: ```bash cp backend/.env.example backend/.env -docker compose --env-file backend/.env up -d mongodb redis +docker compose --env-file backend/.env up -d mongodb npm run dev:backend ``` @@ -119,6 +119,7 @@ npm run playground:down - `POST /proxy/{port}/restart` - `GET /proxy/{port}/health` - `GET /proxy/{port}/logs` +- `WS /proxy/{port}/logs/ws` - `GET /proxy/mitmproxy/options` - `GET /proxy/mitmproxy/examples` diff --git a/TESTING.md b/TESTING.md index e47a155..5a46355 100644 --- a/TESTING.md +++ b/TESTING.md @@ -81,7 +81,6 @@ Porte usate tipicamente: - `3671` backend - `5173` frontend - `27017` mongodb -- `6379` redis - una o più porte proxy/test ## Scenario 1: Laptop con Backend sulla Macchina Host @@ -111,7 +110,7 @@ export SERVICE_SCAN_ROOT="$PWD" Questo fa due cose: -- avvia solo `mongodb` e `redis` in Docker +- avvia `mongodb` in Docker - avvia un piccolo upstream locale in `playground/` Il playground espone: @@ -213,7 +212,7 @@ Usalo per verificare: - CRUD proxy - examples - options -- persistenza Mongo/Redis +- persistenza Mongo - build frontend e wiring API ### Avvio diff --git a/backend/.env.example b/backend/.env.example index 1917bfa..6a11db0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -4,7 +4,6 @@ FRONTEND_PORT=5173 MONGO_INITDB_ROOT_PASSWORD=pass MONGODB_URI=mongodb://theplug:pass@localhost:27017/theplug?authSource=admin MONGODB_DB_NAME=theplug -REDIS_URL=redis://localhost:6379 # Use a random value in real environments. JWT_SECRET_KEY=change-me JWT_ALGORITHM=HS256 diff --git a/backend/Dockerfile b/backend/Dockerfile index 31a22b5..3998d9d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -17,7 +17,6 @@ RUN pip install --no-cache-dir --upgrade pip && \ "pwdlib[argon2,bcrypt]>=0.3.0" \ "pyyaml>=6.0.3" \ "pydantic-settings>=2.13.1" \ - "redis>=7.4.0" \ "ruff>=0.15.10" COPY app ./src diff --git a/backend/app/api/proxy/router.py b/backend/app/api/proxy/router.py index b00df1e..5a1bd48 100644 --- a/backend/app/api/proxy/router.py +++ b/backend/app/api/proxy/router.py @@ -1,8 +1,13 @@ +import asyncio from pathlib import Path from core.config import settings -from core.security import require_authenticated_user -from fastapi import APIRouter, Depends, HTTPException, Request, status +from core.security import ( + InvalidTokenError, + authenticate_token, + require_authenticated_user, +) +from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, status from integrations.docker import get_all_services_in_dir from integrations.docker.discovery import service_scan_root from models.example import AddonExampleCreateInput @@ -33,6 +38,7 @@ from services.mitmproxy.options import ( validate_options, ) from services.mitmproxy.validator import ProxyCodeValidationError, validate_proxy_code +from starlette.websockets import WebSocketDisconnect SUPPORTED_MODES = { "regular", @@ -60,8 +66,8 @@ SUPPORTED_PROTOCOLS = { router = APIRouter( prefix="/proxy", tags=["Proxy"], - dependencies=[Depends(require_authenticated_user)], ) +auth_dependencies = [Depends(require_authenticated_user)] async def _validate_code_or_raise(code: str) -> None: @@ -117,12 +123,71 @@ async def get_proxy_or_404(proxy_port: int): return proxy -@router.get("") +async def authenticate_proxy_socket(websocket: WebSocket) -> None: + token = websocket.query_params.get("token") + if token is None: + authorization = websocket.headers.get("authorization", "") + scheme, _, value = authorization.partition(" ") + if scheme.lower() == "bearer" and value: + token = value + + if not token: + raise InvalidTokenError + + await authenticate_token(token) + + +def proxy_log_path(proxy) -> Path: + if proxy.log_path: + return Path(proxy.log_path) + return manager.log_path_for(proxy) + + +def read_log_snapshot(path: Path, limit: int = 8000) -> str: + if not path.is_file(): + return "" + return path.read_bytes()[-limit:].decode("utf-8", errors="replace") + + +async def watch_socket_disconnect(websocket: WebSocket) -> None: + while True: + await websocket.receive_text() + + +async def stream_proxy_logs(websocket: WebSocket, path: Path) -> None: + offset = path.stat().st_size if path.is_file() else 0 + + while True: + if not path.exists(): + await asyncio.sleep(0.4) + continue + + try: + size = path.stat().st_size + except OSError: + await asyncio.sleep(0.4) + continue + + if size < offset: + offset = size + + if size > offset: + with path.open("rb") as handle: + handle.seek(offset) + chunk = handle.read(size - offset).decode("utf-8", errors="replace") + offset = size + if chunk: + await websocket.send_json({"type": "append", "log": chunk}) + + await asyncio.sleep(0.4) + + +@router.get("", dependencies=auth_dependencies) async def list_proxies_handler(): return await list_proxy_documents() -@router.get("/services") +@router.get("/services", dependencies=auth_dependencies) async def list_services(request: Request, scan_root: str | None = None): _ = settings root = Path(scan_root).expanduser() if scan_root else service_scan_root() @@ -131,17 +196,21 @@ async def list_services(request: Request, scan_root: str | None = None): return {"scan_root": str(root), "services": services} -@router.get("/mitmproxy/options") +@router.get("/mitmproxy/options", dependencies=auth_dependencies) async def list_mitmproxy_options(): return public_options(await load_options()) -@router.get("/mitmproxy/examples") +@router.get("/mitmproxy/examples", dependencies=auth_dependencies) async def list_mitmproxy_examples(): return [*BUILTIN_EXAMPLES, *await list_custom_examples()] -@router.post("/mitmproxy/examples", status_code=status.HTTP_201_CREATED) +@router.post( + "/mitmproxy/examples", + status_code=status.HTTP_201_CREATED, + dependencies=auth_dependencies, +) async def create_mitmproxy_example(payload: AddonExampleCreateInput): await _validate_code_or_raise(payload.code) try: @@ -156,6 +225,7 @@ async def create_mitmproxy_example(payload: AddonExampleCreateInput): @router.delete( "/mitmproxy/examples/{example_id}", status_code=status.HTTP_204_NO_CONTENT, + dependencies=auth_dependencies, ) async def delete_mitmproxy_example(example_id: str): if any(example.id == example_id for example in BUILTIN_EXAMPLES): @@ -172,12 +242,12 @@ async def delete_mitmproxy_example(example_id: str): ) -@router.get("/{proxy_port}") +@router.get("/{proxy_port}", dependencies=auth_dependencies) async def get_proxy(proxy_port: int): return await get_proxy_or_404(proxy_port) -@router.post("", status_code=status.HTTP_201_CREATED) +@router.post("", status_code=status.HTTP_201_CREATED, dependencies=auth_dependencies) async def create_proxy_handler(payload: ProxyCreateInput): existing_proxy = await get_proxy_by_port(payload.proxy_port) if existing_proxy is not None: @@ -195,7 +265,7 @@ async def create_proxy_handler(payload: ProxyCreateInput): return await create_proxy(payload) -@router.put("/{proxy_port}") +@router.put("/{proxy_port}", dependencies=auth_dependencies) async def edit_proxy(proxy_port: int, payload: ProxyUpdateInput): if payload.code is not None: await _validate_code_or_raise(payload.code) @@ -217,7 +287,7 @@ async def edit_proxy(proxy_port: int, payload: ProxyUpdateInput): return updated_proxy -@router.post("/{proxy_port}/start") +@router.post("/{proxy_port}/start", dependencies=auth_dependencies) async def start_proxy(proxy_port: int): proxy = await get_proxy_or_404(proxy_port) @@ -233,14 +303,14 @@ async def start_proxy(proxy_port: int): return await update_proxy_runtime(proxy_port, runtime) -@router.post("/{proxy_port}/stop") +@router.post("/{proxy_port}/stop", dependencies=auth_dependencies) async def stop_proxy(proxy_port: int): proxy = await get_proxy_or_404(proxy_port) runtime = await manager.stop(proxy) return await update_proxy_runtime(proxy_port, runtime) -@router.post("/{proxy_port}/restart") +@router.post("/{proxy_port}/restart", dependencies=auth_dependencies) async def restart_proxy(proxy_port: int): proxy = await get_proxy_or_404(proxy_port) stopped = await manager.stop(proxy) @@ -256,7 +326,7 @@ async def restart_proxy(proxy_port: int): return await update_proxy_runtime(proxy_port, runtime) -@router.get("/{proxy_port}/status") +@router.get("/{proxy_port}/status", dependencies=auth_dependencies) async def proxy_status(proxy_port: int): proxy = await get_proxy_or_404(proxy_port) runtime_status = await manager.status(proxy) @@ -268,29 +338,81 @@ async def proxy_status(proxy_port: int): return {"proxy_port": proxy.proxy_port, "status": runtime_status, "pid": proxy.pid} -@router.get("/{proxy_port}/health") +@router.get("/{proxy_port}/health", dependencies=auth_dependencies) async def proxy_health(proxy_port: int): proxy = await get_proxy_or_404(proxy_port) return await check_proxy(proxy) -@router.get("/{proxy_port}/logs") +@router.get("/{proxy_port}/logs", dependencies=auth_dependencies) async def proxy_logs(proxy_port: int): proxy = await get_proxy_or_404(proxy_port) - if proxy.log_path is None: - return {"proxy_port": proxy.proxy_port, "log": ""} - - path = Path(proxy.log_path) - if not path.is_file(): - return {"proxy_port": proxy.proxy_port, "log": ""} + path = proxy_log_path(proxy) return { "proxy_port": proxy.proxy_port, - "log": path.read_text(encoding="utf-8", errors="replace")[-8000:], + "log": read_log_snapshot(path), } -@router.delete("/{proxy_port}", status_code=status.HTTP_204_NO_CONTENT) +@router.websocket("/{proxy_port}/logs/ws") +async def proxy_logs_socket(websocket: WebSocket, proxy_port: int): + disconnect_task: asyncio.Task[None] | None = None + stream_task: asyncio.Task[None] | None = None + + try: + try: + await authenticate_proxy_socket(websocket) + except InvalidTokenError: + await websocket.close(code=4401) + return + + proxy = await get_proxy_by_port(proxy_port) + if proxy is None: + await websocket.close(code=4404) + return + + path = proxy_log_path(proxy) + await websocket.accept() + await websocket.send_json({"type": "snapshot", "log": read_log_snapshot(path)}) + + disconnect_task = asyncio.create_task(watch_socket_disconnect(websocket)) + stream_task = asyncio.create_task(stream_proxy_logs(websocket, path)) + + done, pending = await asyncio.wait( + {disconnect_task, stream_task}, + return_when=asyncio.FIRST_EXCEPTION, + ) + + for task in pending: + task.cancel() + if pending: + await asyncio.gather(*pending, return_exceptions=True) + + for task in done: + try: + task.result() + except asyncio.CancelledError: + return + except WebSocketDisconnect: + return + except RuntimeError: + await websocket.close(code=1011, reason="log stream failed") + return + except asyncio.CancelledError: + return + finally: + for task in (disconnect_task, stream_task): + if task is None or task.done(): + continue + task.cancel() + + +@router.delete( + "/{proxy_port}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=auth_dependencies, +) async def remove_proxy(proxy_port: int): proxy = await get_proxy_by_port(proxy_port) if proxy is None: diff --git a/backend/app/core/config.py b/backend/app/core/config.py index a83c7ef..8674395 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -12,7 +12,6 @@ class Settings(BaseSettings): MONGODB_URI: str MONGODB_DB_NAME: str - REDIS_URL: str JWT_SECRET_KEY: str JWT_ALGORITHM: str = "HS256" JWT_EXPIRE_MINUTES: int = Field(default=720, gt=0) diff --git a/backend/app/core/redis.py b/backend/app/core/redis.py deleted file mode 100644 index 76c22fc..0000000 --- a/backend/app/core/redis.py +++ /dev/null @@ -1,22 +0,0 @@ -import redis.asyncio as aioredis -from core.config import settings - -_redis_pool: aioredis.Redis | None = None - - -async def init_redis() -> None: - global _redis_pool - _redis_pool = aioredis.from_url( - settings.REDIS_URL, encoding="utf-8", decode_responses=True - ) - - -async def close_redis() -> None: - if _redis_pool: - await _redis_pool.aclose() - - -async def get_redis() -> aioredis.Redis: - if _redis_pool is None: - raise RuntimeError("Redis pool has not been initialized") - return _redis_pool diff --git a/backend/app/main.py b/backend/app/main.py index 1d9ec84..938f3dd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,6 @@ from api.editor.router import router as editor_router from api.proxy.router import router as proxy_router from core.config import settings from core.mongodb import close_mongodb, init_mongodb -from core.redis import close_redis, init_redis from core.security import hash_password from fastapi import FastAPI from integrations.docker import get_all_services_in_dir @@ -21,7 +20,6 @@ from services.mitmproxy.recovery import reconcile_runtime @asynccontextmanager async def lifespan(_: FastAPI): await init_mongodb() - await init_redis() await ensure_auth_indexes() await ensure_single_account( username=settings.AUTH_USERNAME, @@ -56,7 +54,6 @@ async def lifespan(_: FastAPI): try: yield finally: - await close_redis() await close_mongodb() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 53f6b34..a7eae2a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -13,7 +13,6 @@ dependencies = [ "pydantic>=2.12.5", "pydantic-settings>=2.13.1", "pyyaml>=6.0.3", - "redis>=7.4.0", "ruff>=0.15.10", ] diff --git a/backend/tests/api/test_proxy.py b/backend/tests/api/test_proxy.py index fcc8b0b..d85512f 100644 --- a/backend/tests/api/test_proxy.py +++ b/backend/tests/api/test_proxy.py @@ -1,10 +1,12 @@ import importlib +import pytest from core.security import get_auth_context, require_authenticated_user from models.proxy import ProxyCreateInput, ProxyDocument from models.service import ServiceEndpointDocument from services.mitmproxy.examples import BUILTIN_EXAMPLES from services.mitmproxy.validator import ProxyCodeValidationError +from starlette.websockets import WebSocketDisconnect proxy_router_module = importlib.import_module("api.proxy.router") @@ -15,12 +17,14 @@ def make_proxy_document( name: str = "svc", code: str = "proxy-code", port: int = 9000, + log_path: str | None = None, ) -> ProxyDocument: return ProxyDocument( proxy_port=proxy_port, name=name, code=code, service=ServiceEndpointDocument(port=port), + log_path=log_path, ) @@ -292,6 +296,45 @@ def test_update_proxy_returns_422_when_code_validation_fails( assert update_called is False +def test_update_proxy_persists_code_changes(client, monkeypatch: object): + captured: dict[str, object] = {} + updated_code = "def response(flow):\n flow.response.text = 'ok'\n" + + async def fake_validate_proxy_code(code: str) -> None: + captured["validated"] = code + + async def fake_update_proxy( + proxy_port: int, + payload: object, + ) -> ProxyDocument | None: + captured["proxy_port"] = proxy_port + captured["code"] = payload.code + return make_proxy_document( + proxy_port=proxy_port, + code=payload.code, + ) + + monkeypatch.setattr( + proxy_router_module, + "validate_proxy_code", + fake_validate_proxy_code, + ) + monkeypatch.setattr(proxy_router_module, "update_proxy", fake_update_proxy) + + response = client.put( + "/proxy/8123", + json={"code": updated_code}, + ) + + assert response.status_code == 200 + assert response.json()["code"] == updated_code + assert captured == { + "validated": updated_code, + "proxy_port": 8123, + "code": updated_code, + } + + def test_start_proxy_updates_runtime_state(client, monkeypatch: object): proxy = make_proxy_document(proxy_port=4040) captured: dict[str, object] = {} @@ -384,6 +427,49 @@ def test_start_proxy_records_runtime_error(client, monkeypatch: object): assert response.json()["last_error"] == "mitmdump executable not found" +def test_proxy_logs_socket_rejects_missing_token(client): + with pytest.raises(WebSocketDisconnect) as error: + with client.websocket_connect("/proxy/4040/logs/ws"): + pass + + assert error.value.code == 4401 + + +def test_proxy_logs_socket_streams_snapshot_and_append(client, monkeypatch, tmp_path): + log_path = tmp_path / "proxy.log" + log_path.write_text("line one\n", encoding="utf-8") + proxy = make_proxy_document(proxy_port=4040, log_path=str(log_path)) + + async def fake_authenticate(_: object) -> None: + return None + + async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None: + assert proxy_port == 4040 + return proxy + + monkeypatch.setattr( + proxy_router_module, + "authenticate_proxy_socket", + fake_authenticate, + ) + monkeypatch.setattr( + proxy_router_module, + "get_proxy_by_port", + fake_get_proxy_by_port, + ) + + with client.websocket_connect("/proxy/4040/logs/ws?token=test-token") as socket: + snapshot = socket.receive_json() + assert snapshot == {"type": "snapshot", "log": "line one\n"} + + with log_path.open("a", encoding="utf-8") as handle: + handle.write("line two\n") + handle.flush() + + appended = socket.receive_json() + assert appended == {"type": "append", "log": "line two\n"} + + def test_stop_proxy_updates_runtime_state(client, monkeypatch: object): proxy = make_proxy_document(proxy_port=4040).model_copy( update={"status": "running", "pid": 1234} diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index cda91a4..27fb078 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -10,7 +10,6 @@ os.environ.setdefault( "mongodb://theplug:pass@localhost:27017/theplug?authSource=admin", ) os.environ.setdefault("MONGODB_DB_NAME", "theplug") -os.environ.setdefault("REDIS_URL", "redis://localhost:6379") os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key") os.environ.setdefault("JWT_ALGORITHM", "HS256") os.environ.setdefault("JWT_EXPIRE_MINUTES", "720") diff --git a/backend/uv.lock b/backend/uv.lock index e301c14..a1aa930 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1124,15 +1124,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] -[[package]] -name = "redis" -version = "7.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, -] - [[package]] name = "rich" version = "15.0.0" @@ -1304,7 +1295,6 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyyaml" }, - { name = "redis" }, { name = "ruff" }, ] @@ -1324,7 +1314,6 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic-settings", specifier = ">=2.13.1" }, { name = "pyyaml", specifier = ">=6.0.3" }, - { name = "redis", specifier = ">=7.4.0" }, { name = "ruff", specifier = ">=0.15.10" }, ] diff --git a/docker-compose.yml b/docker-compose.yml index b41bbcf..23e1dd5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,14 +10,11 @@ services: environment: MONGODB_URI: mongodb://${MONGO_USER:-theplug}:${MONGO_INITDB_ROOT_PASSWORD:-theplug_dev_password}@mongodb:27017/${MONGO_DB:-theplug}?authSource=admin MONGODB_DB_NAME: ${MONGO_DB:-theplug} - REDIS_URL: redis://redis:6379 ports: - "${API_PORT:-3671}:3671" depends_on: mongodb: condition: service_healthy - redis: - condition: service_healthy frontend: build: @@ -48,22 +45,5 @@ services: retries: 5 start_period: 10s - redis: - image: redis:8-alpine - container_name: theplug-redis - restart: unless-stopped - command: ["redis-server", "--appendonly", "yes"] - ports: - - "6379:6379" - volumes: - - theplug_redis:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 5s - volumes: theplug_mongodb: - theplug_redis: diff --git a/frontend/src/components/CodeEditor.vue b/frontend/src/components/CodeEditor.vue index 406299c..b39f05f 100644 --- a/frontend/src/components/CodeEditor.vue +++ b/frontend/src/components/CodeEditor.vue @@ -22,15 +22,22 @@ const editorTheme = EditorView.theme( { "&": { height: "100%", + maxHeight: "100%", color: "#edf2f7", backgroundColor: "#0b0f12", fontSize: "14px", }, + ".cm-editor": { + height: "100%", + maxHeight: "100%", + }, ".cm-scroller": { + overflow: "auto", fontFamily: "IBM Plex Mono, JetBrains Mono, ui-monospace, monospace", lineHeight: "1.6", }, ".cm-content": { + minWidth: "fit-content", padding: "14px 0", caretColor: "#e7fbff", }, @@ -120,7 +127,6 @@ async function mountEditor() { python(), editorTheme, keymap.of([{ key: "Mod-s", run: emitSave }]), - EditorView.lineWrapping, EditorView.updateListener.of((update) => { if (!update.docChanged) return; const value = update.state.doc.toString(); diff --git a/frontend/src/components/EditorPane.vue b/frontend/src/components/EditorPane.vue index 46769df..6338ec4 100644 --- a/frontend/src/components/EditorPane.vue +++ b/frontend/src/components/EditorPane.vue @@ -2,6 +2,7 @@ import { defineAsyncComponent } from "vue"; import { useControlPlaneContext } from "../composables/useControlPlane"; +import UiIcon from "./UiIcon.vue"; const CodeEditor = defineAsyncComponent(() => import("./CodeEditor.vue")); const plane = useControlPlaneContext(); @@ -10,16 +11,21 @@ const plane = useControlPlaneContext(); diff --git a/frontend/src/composables/useControlPlane.ts b/frontend/src/composables/useControlPlane.ts index 21cfd38..e1bfb56 100644 --- a/frontend/src/composables/useControlPlane.ts +++ b/frontend/src/composables/useControlPlane.ts @@ -9,7 +9,15 @@ import { } from "vue"; import { useRoute, useRouter } from "vue-router"; -import { ApiError, api, getToken, login, logout, setToken } from "../api"; +import { + ApiError, + api, + buildSocketUrl, + getToken, + login, + logout, + setToken, +} from "../api"; import type { AddonExample, DockerService, @@ -181,11 +189,15 @@ export function useControlPlane() { const selectedExampleId = ref(""); const draft = reactive(createDefaultDraft()); const loadedPort = ref(null); + const loadedDraftJson = ref(""); + const loadedCode = ref(draft.code); const wizardOpen = ref(false); const wizardMode = ref<"create" | "edit">("create"); const wizardStep = ref(0); + const logsCleared = ref(false); let noticeTimer: number | null = null; - let logPollTimer: number | null = null; + let logSocket: WebSocket | null = null; + let logReconnectTimer: number | null = null; let syncingRoute = false; const selectedProxy = computed(() => @@ -437,14 +449,32 @@ export function useControlPlane() { ); const codeLineCount = computed(() => draft.code.split("\n").length); + const scriptDirty = computed(() => draft.code !== loadedCode.value); + const draftDirty = computed( + () => serializeDraft(draft) !== loadedDraftJson.value, + ); + const canSaveScript = computed( + () => + !loading.value && + Boolean(selectedProxy.value) && + scriptDirty.value, + ); watch( [selectedPort, proxies], ([port]) => { - if (port === null || port === loadedPort.value) return; + if (port === null) return; const proxy = proxies.value.find((item) => item.proxy_port === port); if (!proxy) return; - hydrateDraft(proxy); + + if (port !== loadedPort.value) { + hydrateDraft(proxy); + return; + } + + if (!wizardOpen.value && !draftDirty.value) { + hydrateDraft(proxy); + } }, { immediate: true }, ); @@ -489,23 +519,17 @@ export function useControlPlane() { } }, { immediate: true }); - watch(selectedProxy, (proxy) => { - if (!proxy) return; - if (!wizardOpen.value) { - hydrateDraft(proxy); - } - }); - watch( [selectedPort, token, logsPaused], - ([port, authToken, paused]) => { - stopLogPolling(); + ([port, authToken, paused], [previousPort]) => { + if (port !== previousPort) { + logs.value = ""; + logFilter.value = ""; + logsCleared.value = false; + } + stopLogStream(); if (!authToken || port === null || paused) return; - void syncLogs(true); - if (typeof window === "undefined") return; - logPollTimer = window.setInterval(() => { - void syncLogs(true); - }, 2000); + void openLogStream(port); }, { immediate: true }, ); @@ -572,10 +596,14 @@ export function useControlPlane() { health.value = null; logs.value = ""; logFilter.value = ""; - Object.assign( - draft, - createDefaultDraft(nextPort(), examples.value[0]?.code ?? draft.code), + logsCleared.value = false; + const nextDraft = createDefaultDraft( + nextPort(), + examples.value[0]?.code ?? draft.code, ); + Object.assign(draft, nextDraft); + loadedDraftJson.value = serializeDraft(nextDraft); + loadedCode.value = nextDraft.code; if (announce) { notice.value = "Draft created."; } @@ -681,6 +709,19 @@ export function useControlPlane() { } } + async function saveScript() { + const proxy = selectedProxy.value; + if (!proxy) return; + + await run(async () => { + const saved = await api.updateProxy(proxy.proxy_port, { + code: draft.code, + }); + syncLoadedCode(saved.code); + await refreshAll(); + }, "Script saved"); + } + async function removeProxy() { const proxy = selectedProxy.value; if (!proxy) return; @@ -726,7 +767,9 @@ export function useControlPlane() { const proxy = selectedProxy.value; if (!proxy) return; try { - logs.value = (await api.logs(proxy.proxy_port)).log; + if (!logsCleared.value) { + logs.value = (await api.logs(proxy.proxy_port)).log; + } } catch (err) { if (!silent) { error.value = err instanceof Error ? err.message : String(err); @@ -734,6 +777,81 @@ export function useControlPlane() { } } + async function openLogStream(proxyPort: number) { + if (!logsCleared.value) { + await syncLogs(true); + } + + if ( + typeof window === "undefined" || + !token.value || + logsPaused.value || + selectedPort.value !== proxyPort + ) { + return; + } + + const socket = new WebSocket(buildSocketUrl(`/proxy/${proxyPort}/logs/ws`)); + logSocket = socket; + + socket.onmessage = (event) => { + if (socket !== logSocket) return; + const frame = parseLogFrame(event.data); + if (frame === null) return; + if (frame.type === "snapshot") { + if (logsCleared.value) return; + logs.value = frame.log; + return; + } + logs.value += frame.log; + }; + + socket.onclose = (event) => { + if (socket !== logSocket) return; + logSocket = null; + + if (event.code === 4401) { + clearSession("Session expired. Sign in again."); + return; + } + + if ( + !token.value || + logsPaused.value || + selectedPort.value !== proxyPort || + event.code === 1000 + ) { + return; + } + + scheduleLogReconnect(proxyPort); + }; + } + + function parseLogFrame(raw: string) { + try { + const payload = JSON.parse(raw); + if ( + (payload.type === "snapshot" || payload.type === "append") && + typeof payload.log === "string" + ) { + return payload as { type: "snapshot" | "append"; log: string }; + } + } catch { + return { type: "append", log: raw } as const; + } + return null; + } + + function scheduleLogReconnect(proxyPort: number) { + if (typeof window === "undefined" || logReconnectTimer !== null) return; + logReconnectTimer = window.setTimeout(() => { + logReconnectTimer = null; + if (!token.value || logsPaused.value || selectedPort.value !== proxyPort) return; + void openLogStream(proxyPort); + }, 1200); + } + async function saveCurrentAsExample() { const name = customExampleName.value.trim(); if (!name) { @@ -852,41 +970,63 @@ export function useControlPlane() { function clearLogs() { logs.value = ""; + logsCleared.value = true; } function cleanDraft(): ProxyDraft { + return draftPayload(draft); + } + + function hydrateDraft(proxy: ProxyDocument) { + const nextDraft = draftPayload(proxy); + Object.assign(draft, nextDraft); + loadedPort.value = proxy.proxy_port; + loadedDraftJson.value = serializeDraft(nextDraft); + loadedCode.value = nextDraft.code; + health.value = null; + logs.value = ""; + } + + function syncLoadedCode(code: string) { + loadedCode.value = code; + + const baseline = loadedDraftJson.value + ? (JSON.parse(loadedDraftJson.value) as ProxyDraft) + : cleanDraft(); + baseline.code = code; + loadedDraftJson.value = serializeDraft(baseline); + } + + function draftPayload(value: ProxyDraft | ProxyDocument): ProxyDraft { return JSON.parse( JSON.stringify({ - proxy_port: draft.proxy_port, - name: draft.name, - code: draft.code, - mode: draft.mode, - mode_spec: draft.mode_spec || null, - protocol: draft.protocol, - listen_host: draft.listen_host, - mitm_options: draft.mitm_options, + proxy_port: value.proxy_port, + name: value.name, + code: value.code, + mode: value.mode, + mode_spec: value.mode_spec || null, + protocol: value.protocol, + listen_host: value.listen_host, + mitm_options: value.mitm_options, healthcheck: { - ...draft.healthcheck, - expected_status: draft.healthcheck.expected_status || null, - protocol: draft.healthcheck.protocol || null, + ...value.healthcheck, + expected_status: value.healthcheck.expected_status || null, + protocol: value.healthcheck.protocol || null, }, service: { - host: draft.service.host, - port: draft.service.port, - bind_host: draft.service.bind_host || null, - name: draft.service.name || null, - folder: draft.service.folder || null, - upstream_port: draft.service.upstream_port || null, + host: value.service.host, + port: value.service.port, + bind_host: value.service.bind_host || null, + name: value.service.name || null, + folder: value.service.folder || null, + upstream_port: value.service.upstream_port || null, }, }), ) as ProxyDraft; } - function hydrateDraft(proxy: ProxyDocument) { - Object.assign(draft, JSON.parse(JSON.stringify(proxy)) as ProxyDraft); - loadedPort.value = proxy.proxy_port; - health.value = null; - logs.value = ""; + function serializeDraft(value: ProxyDraft | ProxyDocument) { + return JSON.stringify(draftPayload(value)); } function bindingsFor(service: DockerService) { @@ -964,7 +1104,7 @@ export function useControlPlane() { } function clearSession(message = "") { - stopLogPolling(); + stopLogStream(); setToken(null); token.value = null; proxies.value = []; @@ -976,18 +1116,26 @@ export function useControlPlane() { health.value = null; logs.value = ""; logFilter.value = ""; + logsCleared.value = false; wizardOpen.value = false; wizardStep.value = 0; wizardMode.value = "create"; Object.assign(draft, createDefaultDraft()); + loadedDraftJson.value = serializeDraft(draft); + loadedCode.value = draft.code; authMessage.value = message; void router.replace({ path: "/proxies", query: {} }); } - function stopLogPolling() { - if (typeof window === "undefined" || logPollTimer === null) return; - window.clearInterval(logPollTimer); - logPollTimer = null; + function stopLogStream() { + if (typeof window !== "undefined" && logReconnectTimer !== null) { + window.clearTimeout(logReconnectTimer); + logReconnectTimer = null; + } + if (logSocket === null) return; + logSocket.onclose = null; + logSocket.close(); + logSocket = null; } return proxyRefs({ @@ -1001,6 +1149,7 @@ export function useControlPlane() { clearLogs, closeWizard, codeLineCount, + canSaveScript, filteredLogs, logFilter, logsPaused, @@ -1036,6 +1185,7 @@ export function useControlPlane() { runtimeAction, saveCurrentAsExample, saveProxy, + saveScript, selectExample, selectedExample, selectedOption, @@ -1044,6 +1194,7 @@ export function useControlPlane() { selectOption, serviceEntries, services, + scriptDirty, setOption, signIn, signOut, diff --git a/frontend/src/styles.css b/frontend/src/styles.css index ec79c1c..8982870 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -574,7 +574,7 @@ pre { .workspace-main-row { display: grid; - grid-template-columns: minmax(0, 5fr) minmax(320px, 3fr); + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 1rem; align-items: start; } @@ -1033,8 +1033,31 @@ pre { font-size: 0.8rem; } +.editor-tools { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 0.7rem; +} + +.editor-save { + white-space: nowrap; +} + +.shortcut-hint { + padding: 0.12rem 0.38rem; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + font-family: "IBM Plex Mono", monospace; + font-size: 0.72rem; +} + .editor-shell { - min-height: clamp(420px, 68vh, 760px); + height: clamp(420px, 68vh, 760px); + max-height: clamp(420px, 68vh, 760px); + min-height: 0; border: 1px solid var(--line); border-radius: var(--radius-md); overflow: hidden; @@ -1044,18 +1067,21 @@ pre { .code-editor-wrap { display: grid; height: 100%; - min-height: clamp(420px, 68vh, 760px); + min-height: 0; + overflow: hidden; } .code-editor { width: 100%; height: 100%; min-height: 0; + overflow: hidden; } .logs-panel { display: grid; grid-template-rows: auto auto minmax(0, 1fr); + height: clamp(420px, 68vh, 760px); max-height: clamp(420px, 68vh, 760px); overflow: hidden; } @@ -1343,7 +1369,17 @@ pre { .editor-shell, .code-editor-wrap, .code-editor { - min-height: 500px; + min-height: 0; + } + + .editor-shell, + .logs-panel { + height: 500px; + max-height: 500px; + } + + .editor-tools { + justify-content: flex-start; } .modal-head, diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ea29105..f797abb 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ port: 5173, proxy: { "/api": { - target: "http://127.0.0.1:3771", + target: "http://127.0.0.1:3871", changeOrigin: true, ws: true, rewrite: (path) => path.replace(/^\/api/, ""), diff --git a/playground/echo/docker-compose.bak.yml b/playground/echo/docker-compose.bak.yml index bddcdc0..53e0c24 100644 --- a/playground/echo/docker-compose.bak.yml +++ b/playground/echo/docker-compose.bak.yml @@ -5,4 +5,4 @@ services: - -listen=:5678 - -text=theplug playground upstream ports: - - 9081:5678 + - 127.0.0.1:38039:5678 diff --git a/playground/echo/docker-compose.yml b/playground/echo/docker-compose.yml index 943b904..bddcdc0 100644 --- a/playground/echo/docker-compose.yml +++ b/playground/echo/docker-compose.yml @@ -5,4 +5,4 @@ services: - -listen=:5678 - -text=theplug playground upstream ports: - - 127.0.0.1:54283:5678 + - 9081:5678 diff --git a/setup.sh b/setup.sh index aa35dbf..d3047ed 100755 --- a/setup.sh +++ b/setup.sh @@ -81,7 +81,7 @@ EOF start_stack() { ensure_env sync_env_values - info "Building and starting backend, frontend, MongoDB and Redis..." + info "Building and starting backend, frontend and MongoDB..." compose up -d --build info "Backend: http://localhost:$(grep -E '^API_PORT=' "$ENV_FILE" | cut -d '=' -f2 || echo 3671)" info "Frontend: http://localhost:$(grep -E '^FRONTEND_PORT=' "$ENV_FILE" | cut -d '=' -f2 || echo 5173)" @@ -90,8 +90,8 @@ start_stack() { start_deps() { ensure_env sync_env_values - info "Starting MongoDB and Redis for host-backend testing..." - compose up -d mongodb redis + info "Starting MongoDB for host-backend testing..." + compose up -d mongodb info "Now run these on the host:" info " npm run dev:backend" info " npm run dev:frontend"