refactor: remove Redis dependency and related code
This commit is contained in:
@@ -6,7 +6,7 @@ Compose.
|
|||||||
## Struttura
|
## Struttura
|
||||||
|
|
||||||
```text
|
```text
|
||||||
backend/ FastAPI, MongoDB, Redis, mitmdump manager
|
backend/ FastAPI, MongoDB, mitmdump manager
|
||||||
frontend/ Vue 3, Vite, CodeMirror + Python LSP
|
frontend/ Vue 3, Vite, CodeMirror + Python LSP
|
||||||
playground/ servizio locale minimale per test end-to-end
|
playground/ servizio locale minimale per test end-to-end
|
||||||
```
|
```
|
||||||
@@ -41,7 +41,7 @@ Per installare anche il servizio systemd user:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp backend/.env.example backend/.env
|
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
|
npm run dev:backend
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -119,6 +119,7 @@ npm run playground:down
|
|||||||
- `POST /proxy/{port}/restart`
|
- `POST /proxy/{port}/restart`
|
||||||
- `GET /proxy/{port}/health`
|
- `GET /proxy/{port}/health`
|
||||||
- `GET /proxy/{port}/logs`
|
- `GET /proxy/{port}/logs`
|
||||||
|
- `WS /proxy/{port}/logs/ws`
|
||||||
- `GET /proxy/mitmproxy/options`
|
- `GET /proxy/mitmproxy/options`
|
||||||
- `GET /proxy/mitmproxy/examples`
|
- `GET /proxy/mitmproxy/examples`
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -81,7 +81,6 @@ Porte usate tipicamente:
|
|||||||
- `3671` backend
|
- `3671` backend
|
||||||
- `5173` frontend
|
- `5173` frontend
|
||||||
- `27017` mongodb
|
- `27017` mongodb
|
||||||
- `6379` redis
|
|
||||||
- una o più porte proxy/test
|
- una o più porte proxy/test
|
||||||
|
|
||||||
## Scenario 1: Laptop con Backend sulla Macchina Host
|
## Scenario 1: Laptop con Backend sulla Macchina Host
|
||||||
@@ -111,7 +110,7 @@ export SERVICE_SCAN_ROOT="$PWD"
|
|||||||
|
|
||||||
Questo fa due cose:
|
Questo fa due cose:
|
||||||
|
|
||||||
- avvia solo `mongodb` e `redis` in Docker
|
- avvia `mongodb` in Docker
|
||||||
- avvia un piccolo upstream locale in `playground/`
|
- avvia un piccolo upstream locale in `playground/`
|
||||||
|
|
||||||
Il playground espone:
|
Il playground espone:
|
||||||
@@ -213,7 +212,7 @@ Usalo per verificare:
|
|||||||
- CRUD proxy
|
- CRUD proxy
|
||||||
- examples
|
- examples
|
||||||
- options
|
- options
|
||||||
- persistenza Mongo/Redis
|
- persistenza Mongo
|
||||||
- build frontend e wiring API
|
- build frontend e wiring API
|
||||||
|
|
||||||
### Avvio
|
### Avvio
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ FRONTEND_PORT=5173
|
|||||||
MONGO_INITDB_ROOT_PASSWORD=pass
|
MONGO_INITDB_ROOT_PASSWORD=pass
|
||||||
MONGODB_URI=mongodb://theplug:pass@localhost:27017/theplug?authSource=admin
|
MONGODB_URI=mongodb://theplug:pass@localhost:27017/theplug?authSource=admin
|
||||||
MONGODB_DB_NAME=theplug
|
MONGODB_DB_NAME=theplug
|
||||||
REDIS_URL=redis://localhost:6379
|
|
||||||
# Use a random value in real environments.
|
# Use a random value in real environments.
|
||||||
JWT_SECRET_KEY=change-me
|
JWT_SECRET_KEY=change-me
|
||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
|||||||
"pwdlib[argon2,bcrypt]>=0.3.0" \
|
"pwdlib[argon2,bcrypt]>=0.3.0" \
|
||||||
"pyyaml>=6.0.3" \
|
"pyyaml>=6.0.3" \
|
||||||
"pydantic-settings>=2.13.1" \
|
"pydantic-settings>=2.13.1" \
|
||||||
"redis>=7.4.0" \
|
|
||||||
"ruff>=0.15.10"
|
"ruff>=0.15.10"
|
||||||
|
|
||||||
COPY app ./src
|
COPY app ./src
|
||||||
|
|||||||
+147
-25
@@ -1,8 +1,13 @@
|
|||||||
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from core.config import settings
|
from core.config import settings
|
||||||
from core.security import require_authenticated_user
|
from core.security import (
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
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 import get_all_services_in_dir
|
||||||
from integrations.docker.discovery import service_scan_root
|
from integrations.docker.discovery import service_scan_root
|
||||||
from models.example import AddonExampleCreateInput
|
from models.example import AddonExampleCreateInput
|
||||||
@@ -33,6 +38,7 @@ from services.mitmproxy.options import (
|
|||||||
validate_options,
|
validate_options,
|
||||||
)
|
)
|
||||||
from services.mitmproxy.validator import ProxyCodeValidationError, validate_proxy_code
|
from services.mitmproxy.validator import ProxyCodeValidationError, validate_proxy_code
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
SUPPORTED_MODES = {
|
SUPPORTED_MODES = {
|
||||||
"regular",
|
"regular",
|
||||||
@@ -60,8 +66,8 @@ SUPPORTED_PROTOCOLS = {
|
|||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/proxy",
|
prefix="/proxy",
|
||||||
tags=["Proxy"],
|
tags=["Proxy"],
|
||||||
dependencies=[Depends(require_authenticated_user)],
|
|
||||||
)
|
)
|
||||||
|
auth_dependencies = [Depends(require_authenticated_user)]
|
||||||
|
|
||||||
|
|
||||||
async def _validate_code_or_raise(code: str) -> None:
|
async def _validate_code_or_raise(code: str) -> None:
|
||||||
@@ -117,12 +123,71 @@ async def get_proxy_or_404(proxy_port: int):
|
|||||||
return proxy
|
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():
|
async def list_proxies_handler():
|
||||||
return await list_proxy_documents()
|
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):
|
async def list_services(request: Request, scan_root: str | None = None):
|
||||||
_ = settings
|
_ = settings
|
||||||
root = Path(scan_root).expanduser() if scan_root else service_scan_root()
|
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}
|
return {"scan_root": str(root), "services": services}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/mitmproxy/options")
|
@router.get("/mitmproxy/options", dependencies=auth_dependencies)
|
||||||
async def list_mitmproxy_options():
|
async def list_mitmproxy_options():
|
||||||
return public_options(await load_options())
|
return public_options(await load_options())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/mitmproxy/examples")
|
@router.get("/mitmproxy/examples", dependencies=auth_dependencies)
|
||||||
async def list_mitmproxy_examples():
|
async def list_mitmproxy_examples():
|
||||||
return [*BUILTIN_EXAMPLES, *await list_custom_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):
|
async def create_mitmproxy_example(payload: AddonExampleCreateInput):
|
||||||
await _validate_code_or_raise(payload.code)
|
await _validate_code_or_raise(payload.code)
|
||||||
try:
|
try:
|
||||||
@@ -156,6 +225,7 @@ async def create_mitmproxy_example(payload: AddonExampleCreateInput):
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/mitmproxy/examples/{example_id}",
|
"/mitmproxy/examples/{example_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=auth_dependencies,
|
||||||
)
|
)
|
||||||
async def delete_mitmproxy_example(example_id: str):
|
async def delete_mitmproxy_example(example_id: str):
|
||||||
if any(example.id == example_id for example in BUILTIN_EXAMPLES):
|
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):
|
async def get_proxy(proxy_port: int):
|
||||||
return await get_proxy_or_404(proxy_port)
|
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):
|
async def create_proxy_handler(payload: ProxyCreateInput):
|
||||||
existing_proxy = await get_proxy_by_port(payload.proxy_port)
|
existing_proxy = await get_proxy_by_port(payload.proxy_port)
|
||||||
if existing_proxy is not None:
|
if existing_proxy is not None:
|
||||||
@@ -195,7 +265,7 @@ async def create_proxy_handler(payload: ProxyCreateInput):
|
|||||||
return await create_proxy(payload)
|
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):
|
async def edit_proxy(proxy_port: int, payload: ProxyUpdateInput):
|
||||||
if payload.code is not None:
|
if payload.code is not None:
|
||||||
await _validate_code_or_raise(payload.code)
|
await _validate_code_or_raise(payload.code)
|
||||||
@@ -217,7 +287,7 @@ async def edit_proxy(proxy_port: int, payload: ProxyUpdateInput):
|
|||||||
return updated_proxy
|
return updated_proxy
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{proxy_port}/start")
|
@router.post("/{proxy_port}/start", dependencies=auth_dependencies)
|
||||||
async def start_proxy(proxy_port: int):
|
async def start_proxy(proxy_port: int):
|
||||||
proxy = await get_proxy_or_404(proxy_port)
|
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)
|
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):
|
async def stop_proxy(proxy_port: int):
|
||||||
proxy = await get_proxy_or_404(proxy_port)
|
proxy = await get_proxy_or_404(proxy_port)
|
||||||
runtime = await manager.stop(proxy)
|
runtime = await manager.stop(proxy)
|
||||||
return await update_proxy_runtime(proxy_port, runtime)
|
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):
|
async def restart_proxy(proxy_port: int):
|
||||||
proxy = await get_proxy_or_404(proxy_port)
|
proxy = await get_proxy_or_404(proxy_port)
|
||||||
stopped = await manager.stop(proxy)
|
stopped = await manager.stop(proxy)
|
||||||
@@ -256,7 +326,7 @@ async def restart_proxy(proxy_port: int):
|
|||||||
return await update_proxy_runtime(proxy_port, runtime)
|
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):
|
async def proxy_status(proxy_port: int):
|
||||||
proxy = await get_proxy_or_404(proxy_port)
|
proxy = await get_proxy_or_404(proxy_port)
|
||||||
runtime_status = await manager.status(proxy)
|
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}
|
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):
|
async def proxy_health(proxy_port: int):
|
||||||
proxy = await get_proxy_or_404(proxy_port)
|
proxy = await get_proxy_or_404(proxy_port)
|
||||||
return await check_proxy(proxy)
|
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):
|
async def proxy_logs(proxy_port: int):
|
||||||
proxy = await get_proxy_or_404(proxy_port)
|
proxy = await get_proxy_or_404(proxy_port)
|
||||||
if proxy.log_path is None:
|
path = proxy_log_path(proxy)
|
||||||
return {"proxy_port": proxy.proxy_port, "log": ""}
|
|
||||||
|
|
||||||
path = Path(proxy.log_path)
|
|
||||||
if not path.is_file():
|
|
||||||
return {"proxy_port": proxy.proxy_port, "log": ""}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"proxy_port": proxy.proxy_port,
|
"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):
|
async def remove_proxy(proxy_port: int):
|
||||||
proxy = await get_proxy_by_port(proxy_port)
|
proxy = await get_proxy_by_port(proxy_port)
|
||||||
if proxy is None:
|
if proxy is None:
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
MONGODB_URI: str
|
MONGODB_URI: str
|
||||||
MONGODB_DB_NAME: str
|
MONGODB_DB_NAME: str
|
||||||
REDIS_URL: str
|
|
||||||
JWT_SECRET_KEY: str
|
JWT_SECRET_KEY: str
|
||||||
JWT_ALGORITHM: str = "HS256"
|
JWT_ALGORITHM: str = "HS256"
|
||||||
JWT_EXPIRE_MINUTES: int = Field(default=720, gt=0)
|
JWT_EXPIRE_MINUTES: int = Field(default=720, gt=0)
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -7,7 +7,6 @@ from api.editor.router import router as editor_router
|
|||||||
from api.proxy.router import router as proxy_router
|
from api.proxy.router import router as proxy_router
|
||||||
from core.config import settings
|
from core.config import settings
|
||||||
from core.mongodb import close_mongodb, init_mongodb
|
from core.mongodb import close_mongodb, init_mongodb
|
||||||
from core.redis import close_redis, init_redis
|
|
||||||
from core.security import hash_password
|
from core.security import hash_password
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from integrations.docker import get_all_services_in_dir
|
from integrations.docker import get_all_services_in_dir
|
||||||
@@ -21,7 +20,6 @@ from services.mitmproxy.recovery import reconcile_runtime
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_: FastAPI):
|
async def lifespan(_: FastAPI):
|
||||||
await init_mongodb()
|
await init_mongodb()
|
||||||
await init_redis()
|
|
||||||
await ensure_auth_indexes()
|
await ensure_auth_indexes()
|
||||||
await ensure_single_account(
|
await ensure_single_account(
|
||||||
username=settings.AUTH_USERNAME,
|
username=settings.AUTH_USERNAME,
|
||||||
@@ -56,7 +54,6 @@ async def lifespan(_: FastAPI):
|
|||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
await close_redis()
|
|
||||||
await close_mongodb()
|
await close_mongodb()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ dependencies = [
|
|||||||
"pydantic>=2.12.5",
|
"pydantic>=2.12.5",
|
||||||
"pydantic-settings>=2.13.1",
|
"pydantic-settings>=2.13.1",
|
||||||
"pyyaml>=6.0.3",
|
"pyyaml>=6.0.3",
|
||||||
"redis>=7.4.0",
|
|
||||||
"ruff>=0.15.10",
|
"ruff>=0.15.10",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
from core.security import get_auth_context, require_authenticated_user
|
from core.security import get_auth_context, require_authenticated_user
|
||||||
from models.proxy import ProxyCreateInput, ProxyDocument
|
from models.proxy import ProxyCreateInput, ProxyDocument
|
||||||
from models.service import ServiceEndpointDocument
|
from models.service import ServiceEndpointDocument
|
||||||
from services.mitmproxy.examples import BUILTIN_EXAMPLES
|
from services.mitmproxy.examples import BUILTIN_EXAMPLES
|
||||||
from services.mitmproxy.validator import ProxyCodeValidationError
|
from services.mitmproxy.validator import ProxyCodeValidationError
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
proxy_router_module = importlib.import_module("api.proxy.router")
|
proxy_router_module = importlib.import_module("api.proxy.router")
|
||||||
|
|
||||||
@@ -15,12 +17,14 @@ def make_proxy_document(
|
|||||||
name: str = "svc",
|
name: str = "svc",
|
||||||
code: str = "proxy-code",
|
code: str = "proxy-code",
|
||||||
port: int = 9000,
|
port: int = 9000,
|
||||||
|
log_path: str | None = None,
|
||||||
) -> ProxyDocument:
|
) -> ProxyDocument:
|
||||||
return ProxyDocument(
|
return ProxyDocument(
|
||||||
proxy_port=proxy_port,
|
proxy_port=proxy_port,
|
||||||
name=name,
|
name=name,
|
||||||
code=code,
|
code=code,
|
||||||
service=ServiceEndpointDocument(port=port),
|
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
|
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):
|
def test_start_proxy_updates_runtime_state(client, monkeypatch: object):
|
||||||
proxy = make_proxy_document(proxy_port=4040)
|
proxy = make_proxy_document(proxy_port=4040)
|
||||||
captured: dict[str, object] = {}
|
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"
|
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):
|
def test_stop_proxy_updates_runtime_state(client, monkeypatch: object):
|
||||||
proxy = make_proxy_document(proxy_port=4040).model_copy(
|
proxy = make_proxy_document(proxy_port=4040).model_copy(
|
||||||
update={"status": "running", "pid": 1234}
|
update={"status": "running", "pid": 1234}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ os.environ.setdefault(
|
|||||||
"mongodb://theplug:pass@localhost:27017/theplug?authSource=admin",
|
"mongodb://theplug:pass@localhost:27017/theplug?authSource=admin",
|
||||||
)
|
)
|
||||||
os.environ.setdefault("MONGODB_DB_NAME", "theplug")
|
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_SECRET_KEY", "test-secret-key")
|
||||||
os.environ.setdefault("JWT_ALGORITHM", "HS256")
|
os.environ.setdefault("JWT_ALGORITHM", "HS256")
|
||||||
os.environ.setdefault("JWT_EXPIRE_MINUTES", "720")
|
os.environ.setdefault("JWT_EXPIRE_MINUTES", "720")
|
||||||
|
|||||||
Generated
-11
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "rich"
|
name = "rich"
|
||||||
version = "15.0.0"
|
version = "15.0.0"
|
||||||
@@ -1304,7 +1295,6 @@ dependencies = [
|
|||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
{ name = "redis" },
|
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1324,7 +1314,6 @@ requires-dist = [
|
|||||||
{ name = "pydantic", specifier = ">=2.12.5" },
|
{ name = "pydantic", specifier = ">=2.12.5" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.13.1" },
|
{ name = "pydantic-settings", specifier = ">=2.13.1" },
|
||||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||||
{ name = "redis", specifier = ">=7.4.0" },
|
|
||||||
{ name = "ruff", specifier = ">=0.15.10" },
|
{ name = "ruff", specifier = ">=0.15.10" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,11 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
MONGODB_URI: mongodb://${MONGO_USER:-theplug}:${MONGO_INITDB_ROOT_PASSWORD:-theplug_dev_password}@mongodb:27017/${MONGO_DB:-theplug}?authSource=admin
|
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}
|
MONGODB_DB_NAME: ${MONGO_DB:-theplug}
|
||||||
REDIS_URL: redis://redis:6379
|
|
||||||
ports:
|
ports:
|
||||||
- "${API_PORT:-3671}:3671"
|
- "${API_PORT:-3671}:3671"
|
||||||
depends_on:
|
depends_on:
|
||||||
mongodb:
|
mongodb:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
@@ -48,22 +45,5 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
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:
|
volumes:
|
||||||
theplug_mongodb:
|
theplug_mongodb:
|
||||||
theplug_redis:
|
|
||||||
|
|||||||
@@ -22,15 +22,22 @@ const editorTheme = EditorView.theme(
|
|||||||
{
|
{
|
||||||
"&": {
|
"&": {
|
||||||
height: "100%",
|
height: "100%",
|
||||||
|
maxHeight: "100%",
|
||||||
color: "#edf2f7",
|
color: "#edf2f7",
|
||||||
backgroundColor: "#0b0f12",
|
backgroundColor: "#0b0f12",
|
||||||
fontSize: "14px",
|
fontSize: "14px",
|
||||||
},
|
},
|
||||||
|
".cm-editor": {
|
||||||
|
height: "100%",
|
||||||
|
maxHeight: "100%",
|
||||||
|
},
|
||||||
".cm-scroller": {
|
".cm-scroller": {
|
||||||
|
overflow: "auto",
|
||||||
fontFamily: "IBM Plex Mono, JetBrains Mono, ui-monospace, monospace",
|
fontFamily: "IBM Plex Mono, JetBrains Mono, ui-monospace, monospace",
|
||||||
lineHeight: "1.6",
|
lineHeight: "1.6",
|
||||||
},
|
},
|
||||||
".cm-content": {
|
".cm-content": {
|
||||||
|
minWidth: "fit-content",
|
||||||
padding: "14px 0",
|
padding: "14px 0",
|
||||||
caretColor: "#e7fbff",
|
caretColor: "#e7fbff",
|
||||||
},
|
},
|
||||||
@@ -120,7 +127,6 @@ async function mountEditor() {
|
|||||||
python(),
|
python(),
|
||||||
editorTheme,
|
editorTheme,
|
||||||
keymap.of([{ key: "Mod-s", run: emitSave }]),
|
keymap.of([{ key: "Mod-s", run: emitSave }]),
|
||||||
EditorView.lineWrapping,
|
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (!update.docChanged) return;
|
if (!update.docChanged) return;
|
||||||
const value = update.state.doc.toString();
|
const value = update.state.doc.toString();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { defineAsyncComponent } from "vue";
|
import { defineAsyncComponent } from "vue";
|
||||||
|
|
||||||
import { useControlPlaneContext } from "../composables/useControlPlane";
|
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||||
|
import UiIcon from "./UiIcon.vue";
|
||||||
|
|
||||||
const CodeEditor = defineAsyncComponent(() => import("./CodeEditor.vue"));
|
const CodeEditor = defineAsyncComponent(() => import("./CodeEditor.vue"));
|
||||||
const plane = useControlPlaneContext();
|
const plane = useControlPlaneContext();
|
||||||
@@ -10,16 +11,21 @@ const plane = useControlPlaneContext();
|
|||||||
<template>
|
<template>
|
||||||
<section class="editor-pane panel">
|
<section class="editor-pane panel">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
<div>
|
|
||||||
<h2>Proxy script</h2>
|
<h2>Proxy script</h2>
|
||||||
</div>
|
<div class="editor-tools">
|
||||||
<div class="editor-meta">
|
<button
|
||||||
<span>{{ plane.codeLineCount }} lines</span>
|
class="ghost button-icon editor-save"
|
||||||
<span>{{ plane.draft.mode }} / {{ plane.draft.protocol }}</span>
|
:disabled="!plane.canSaveScript"
|
||||||
|
@click="plane.saveScript"
|
||||||
|
>
|
||||||
|
<UiIcon name="save" />
|
||||||
|
<span>Save script</span>
|
||||||
|
<span class="shortcut-hint">Ctrl/Cmd+S</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-shell">
|
<div class="editor-shell">
|
||||||
<CodeEditor v-model="plane.draft.code" @save="plane.saveProxy" />
|
<CodeEditor v-model="plane.draft.code" @save="plane.saveScript" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -9,7 +9,15 @@ import {
|
|||||||
} from "vue";
|
} from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
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 {
|
import type {
|
||||||
AddonExample,
|
AddonExample,
|
||||||
DockerService,
|
DockerService,
|
||||||
@@ -181,11 +189,15 @@ export function useControlPlane() {
|
|||||||
const selectedExampleId = ref("");
|
const selectedExampleId = ref("");
|
||||||
const draft = reactive<ProxyDraft>(createDefaultDraft());
|
const draft = reactive<ProxyDraft>(createDefaultDraft());
|
||||||
const loadedPort = ref<number | null>(null);
|
const loadedPort = ref<number | null>(null);
|
||||||
|
const loadedDraftJson = ref("");
|
||||||
|
const loadedCode = ref(draft.code);
|
||||||
const wizardOpen = ref(false);
|
const wizardOpen = ref(false);
|
||||||
const wizardMode = ref<"create" | "edit">("create");
|
const wizardMode = ref<"create" | "edit">("create");
|
||||||
const wizardStep = ref(0);
|
const wizardStep = ref(0);
|
||||||
|
const logsCleared = ref(false);
|
||||||
let noticeTimer: number | null = null;
|
let noticeTimer: number | null = null;
|
||||||
let logPollTimer: number | null = null;
|
let logSocket: WebSocket | null = null;
|
||||||
|
let logReconnectTimer: number | null = null;
|
||||||
let syncingRoute = false;
|
let syncingRoute = false;
|
||||||
|
|
||||||
const selectedProxy = computed(() =>
|
const selectedProxy = computed(() =>
|
||||||
@@ -437,14 +449,32 @@ export function useControlPlane() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const codeLineCount = computed(() => draft.code.split("\n").length);
|
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(
|
watch(
|
||||||
[selectedPort, proxies],
|
[selectedPort, proxies],
|
||||||
([port]) => {
|
([port]) => {
|
||||||
if (port === null || port === loadedPort.value) return;
|
if (port === null) return;
|
||||||
const proxy = proxies.value.find((item) => item.proxy_port === port);
|
const proxy = proxies.value.find((item) => item.proxy_port === port);
|
||||||
if (!proxy) return;
|
if (!proxy) return;
|
||||||
|
|
||||||
|
if (port !== loadedPort.value) {
|
||||||
hydrateDraft(proxy);
|
hydrateDraft(proxy);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wizardOpen.value && !draftDirty.value) {
|
||||||
|
hydrateDraft(proxy);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
@@ -489,23 +519,17 @@ export function useControlPlane() {
|
|||||||
}
|
}
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
watch(selectedProxy, (proxy) => {
|
|
||||||
if (!proxy) return;
|
|
||||||
if (!wizardOpen.value) {
|
|
||||||
hydrateDraft(proxy);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[selectedPort, token, logsPaused],
|
[selectedPort, token, logsPaused],
|
||||||
([port, authToken, paused]) => {
|
([port, authToken, paused], [previousPort]) => {
|
||||||
stopLogPolling();
|
if (port !== previousPort) {
|
||||||
|
logs.value = "";
|
||||||
|
logFilter.value = "";
|
||||||
|
logsCleared.value = false;
|
||||||
|
}
|
||||||
|
stopLogStream();
|
||||||
if (!authToken || port === null || paused) return;
|
if (!authToken || port === null || paused) return;
|
||||||
void syncLogs(true);
|
void openLogStream(port);
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
logPollTimer = window.setInterval(() => {
|
|
||||||
void syncLogs(true);
|
|
||||||
}, 2000);
|
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
@@ -572,10 +596,14 @@ export function useControlPlane() {
|
|||||||
health.value = null;
|
health.value = null;
|
||||||
logs.value = "";
|
logs.value = "";
|
||||||
logFilter.value = "";
|
logFilter.value = "";
|
||||||
Object.assign(
|
logsCleared.value = false;
|
||||||
draft,
|
const nextDraft = createDefaultDraft(
|
||||||
createDefaultDraft(nextPort(), examples.value[0]?.code ?? draft.code),
|
nextPort(),
|
||||||
|
examples.value[0]?.code ?? draft.code,
|
||||||
);
|
);
|
||||||
|
Object.assign(draft, nextDraft);
|
||||||
|
loadedDraftJson.value = serializeDraft(nextDraft);
|
||||||
|
loadedCode.value = nextDraft.code;
|
||||||
if (announce) {
|
if (announce) {
|
||||||
notice.value = "Draft created.";
|
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() {
|
async function removeProxy() {
|
||||||
const proxy = selectedProxy.value;
|
const proxy = selectedProxy.value;
|
||||||
if (!proxy) return;
|
if (!proxy) return;
|
||||||
@@ -726,7 +767,9 @@ export function useControlPlane() {
|
|||||||
const proxy = selectedProxy.value;
|
const proxy = selectedProxy.value;
|
||||||
if (!proxy) return;
|
if (!proxy) return;
|
||||||
try {
|
try {
|
||||||
|
if (!logsCleared.value) {
|
||||||
logs.value = (await api.logs(proxy.proxy_port)).log;
|
logs.value = (await api.logs(proxy.proxy_port)).log;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
error.value = err instanceof Error ? err.message : String(err);
|
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() {
|
async function saveCurrentAsExample() {
|
||||||
const name = customExampleName.value.trim();
|
const name = customExampleName.value.trim();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -852,41 +970,63 @@ export function useControlPlane() {
|
|||||||
|
|
||||||
function clearLogs() {
|
function clearLogs() {
|
||||||
logs.value = "";
|
logs.value = "";
|
||||||
|
logsCleared.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanDraft(): ProxyDraft {
|
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(
|
return JSON.parse(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
proxy_port: draft.proxy_port,
|
proxy_port: value.proxy_port,
|
||||||
name: draft.name,
|
name: value.name,
|
||||||
code: draft.code,
|
code: value.code,
|
||||||
mode: draft.mode,
|
mode: value.mode,
|
||||||
mode_spec: draft.mode_spec || null,
|
mode_spec: value.mode_spec || null,
|
||||||
protocol: draft.protocol,
|
protocol: value.protocol,
|
||||||
listen_host: draft.listen_host,
|
listen_host: value.listen_host,
|
||||||
mitm_options: draft.mitm_options,
|
mitm_options: value.mitm_options,
|
||||||
healthcheck: {
|
healthcheck: {
|
||||||
...draft.healthcheck,
|
...value.healthcheck,
|
||||||
expected_status: draft.healthcheck.expected_status || null,
|
expected_status: value.healthcheck.expected_status || null,
|
||||||
protocol: draft.healthcheck.protocol || null,
|
protocol: value.healthcheck.protocol || null,
|
||||||
},
|
},
|
||||||
service: {
|
service: {
|
||||||
host: draft.service.host,
|
host: value.service.host,
|
||||||
port: draft.service.port,
|
port: value.service.port,
|
||||||
bind_host: draft.service.bind_host || null,
|
bind_host: value.service.bind_host || null,
|
||||||
name: draft.service.name || null,
|
name: value.service.name || null,
|
||||||
folder: draft.service.folder || null,
|
folder: value.service.folder || null,
|
||||||
upstream_port: draft.service.upstream_port || null,
|
upstream_port: value.service.upstream_port || null,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
) as ProxyDraft;
|
) as ProxyDraft;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hydrateDraft(proxy: ProxyDocument) {
|
function serializeDraft(value: ProxyDraft | ProxyDocument) {
|
||||||
Object.assign(draft, JSON.parse(JSON.stringify(proxy)) as ProxyDraft);
|
return JSON.stringify(draftPayload(value));
|
||||||
loadedPort.value = proxy.proxy_port;
|
|
||||||
health.value = null;
|
|
||||||
logs.value = "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindingsFor(service: DockerService) {
|
function bindingsFor(service: DockerService) {
|
||||||
@@ -964,7 +1104,7 @@ export function useControlPlane() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearSession(message = "") {
|
function clearSession(message = "") {
|
||||||
stopLogPolling();
|
stopLogStream();
|
||||||
setToken(null);
|
setToken(null);
|
||||||
token.value = null;
|
token.value = null;
|
||||||
proxies.value = [];
|
proxies.value = [];
|
||||||
@@ -976,18 +1116,26 @@ export function useControlPlane() {
|
|||||||
health.value = null;
|
health.value = null;
|
||||||
logs.value = "";
|
logs.value = "";
|
||||||
logFilter.value = "";
|
logFilter.value = "";
|
||||||
|
logsCleared.value = false;
|
||||||
wizardOpen.value = false;
|
wizardOpen.value = false;
|
||||||
wizardStep.value = 0;
|
wizardStep.value = 0;
|
||||||
wizardMode.value = "create";
|
wizardMode.value = "create";
|
||||||
Object.assign(draft, createDefaultDraft());
|
Object.assign(draft, createDefaultDraft());
|
||||||
|
loadedDraftJson.value = serializeDraft(draft);
|
||||||
|
loadedCode.value = draft.code;
|
||||||
authMessage.value = message;
|
authMessage.value = message;
|
||||||
void router.replace({ path: "/proxies", query: {} });
|
void router.replace({ path: "/proxies", query: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopLogPolling() {
|
function stopLogStream() {
|
||||||
if (typeof window === "undefined" || logPollTimer === null) return;
|
if (typeof window !== "undefined" && logReconnectTimer !== null) {
|
||||||
window.clearInterval(logPollTimer);
|
window.clearTimeout(logReconnectTimer);
|
||||||
logPollTimer = null;
|
logReconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (logSocket === null) return;
|
||||||
|
logSocket.onclose = null;
|
||||||
|
logSocket.close();
|
||||||
|
logSocket = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return proxyRefs({
|
return proxyRefs({
|
||||||
@@ -1001,6 +1149,7 @@ export function useControlPlane() {
|
|||||||
clearLogs,
|
clearLogs,
|
||||||
closeWizard,
|
closeWizard,
|
||||||
codeLineCount,
|
codeLineCount,
|
||||||
|
canSaveScript,
|
||||||
filteredLogs,
|
filteredLogs,
|
||||||
logFilter,
|
logFilter,
|
||||||
logsPaused,
|
logsPaused,
|
||||||
@@ -1036,6 +1185,7 @@ export function useControlPlane() {
|
|||||||
runtimeAction,
|
runtimeAction,
|
||||||
saveCurrentAsExample,
|
saveCurrentAsExample,
|
||||||
saveProxy,
|
saveProxy,
|
||||||
|
saveScript,
|
||||||
selectExample,
|
selectExample,
|
||||||
selectedExample,
|
selectedExample,
|
||||||
selectedOption,
|
selectedOption,
|
||||||
@@ -1044,6 +1194,7 @@ export function useControlPlane() {
|
|||||||
selectOption,
|
selectOption,
|
||||||
serviceEntries,
|
serviceEntries,
|
||||||
services,
|
services,
|
||||||
|
scriptDirty,
|
||||||
setOption,
|
setOption,
|
||||||
signIn,
|
signIn,
|
||||||
signOut,
|
signOut,
|
||||||
|
|||||||
+40
-4
@@ -574,7 +574,7 @@ pre {
|
|||||||
|
|
||||||
.workspace-main-row {
|
.workspace-main-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 5fr) minmax(320px, 3fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
@@ -1033,8 +1033,31 @@ pre {
|
|||||||
font-size: 0.8rem;
|
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 {
|
.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: 1px solid var(--line);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1044,18 +1067,21 @@ pre {
|
|||||||
.code-editor-wrap {
|
.code-editor-wrap {
|
||||||
display: grid;
|
display: grid;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: clamp(420px, 68vh, 760px);
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-editor {
|
.code-editor {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logs-panel {
|
.logs-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto auto minmax(0, 1fr);
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
|
height: clamp(420px, 68vh, 760px);
|
||||||
max-height: clamp(420px, 68vh, 760px);
|
max-height: clamp(420px, 68vh, 760px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -1343,7 +1369,17 @@ pre {
|
|||||||
.editor-shell,
|
.editor-shell,
|
||||||
.code-editor-wrap,
|
.code-editor-wrap,
|
||||||
.code-editor {
|
.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,
|
.modal-head,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default defineConfig({
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: "http://127.0.0.1:3771",
|
target: "http://127.0.0.1:3871",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
ws: true,
|
ws: true,
|
||||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||||
|
|||||||
@@ -5,4 +5,4 @@ services:
|
|||||||
- -listen=:5678
|
- -listen=:5678
|
||||||
- -text=theplug playground upstream
|
- -text=theplug playground upstream
|
||||||
ports:
|
ports:
|
||||||
- 9081:5678
|
- 127.0.0.1:38039:5678
|
||||||
|
|||||||
@@ -5,4 +5,4 @@ services:
|
|||||||
- -listen=:5678
|
- -listen=:5678
|
||||||
- -text=theplug playground upstream
|
- -text=theplug playground upstream
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:54283:5678
|
- 9081:5678
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ EOF
|
|||||||
start_stack() {
|
start_stack() {
|
||||||
ensure_env
|
ensure_env
|
||||||
sync_env_values
|
sync_env_values
|
||||||
info "Building and starting backend, frontend, MongoDB and Redis..."
|
info "Building and starting backend, frontend and MongoDB..."
|
||||||
compose up -d --build
|
compose up -d --build
|
||||||
info "Backend: http://localhost:$(grep -E '^API_PORT=' "$ENV_FILE" | cut -d '=' -f2 || echo 3671)"
|
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)"
|
info "Frontend: http://localhost:$(grep -E '^FRONTEND_PORT=' "$ENV_FILE" | cut -d '=' -f2 || echo 5173)"
|
||||||
@@ -90,8 +90,8 @@ start_stack() {
|
|||||||
start_deps() {
|
start_deps() {
|
||||||
ensure_env
|
ensure_env
|
||||||
sync_env_values
|
sync_env_values
|
||||||
info "Starting MongoDB and Redis for host-backend testing..."
|
info "Starting MongoDB for host-backend testing..."
|
||||||
compose up -d mongodb redis
|
compose up -d mongodb
|
||||||
info "Now run these on the host:"
|
info "Now run these on the host:"
|
||||||
info " npm run dev:backend"
|
info " npm run dev:backend"
|
||||||
info " npm run dev:frontend"
|
info " npm run dev:frontend"
|
||||||
|
|||||||
Reference in New Issue
Block a user